chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 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,192 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Ties Admin services end-to-end against a throwaway per-run database — mirrors the
/// Configuration fixture pattern. Spins up a fresh DB, applies migrations, exercises the
/// create-cluster → add-equipment → validate → publish → rollback happy path, then drops the
/// DB in Dispose. Confirms the stored procedures and managed validators agree with the UI
/// services.
/// </summary>
[Trait("Category", "Integration")]
public sealed class AdminServicesIntegrationTests : IDisposable
{
private const string DefaultServer = "localhost,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
private readonly string _databaseName = $"OtOpcUaAdminTest_{Guid.NewGuid():N}";
private readonly string _connectionString;
public AdminServicesIntegrationTests()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
_connectionString =
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
using var ctx = NewContext();
ctx.Database.Migrate();
}
public void Dispose()
{
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString)
{ InitialCatalog = "master" }.ConnectionString);
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();
}
private OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(_connectionString)
.Options;
return new OtOpcUaConfigDbContext(opts);
}
[Fact]
public async Task Create_cluster_add_equipment_validate_publish_roundtrips_the_full_admin_flow()
{
// 1. Create cluster + draft.
await using (var ctx = NewContext())
{
var clusterSvc = new ClusterService(ctx);
await clusterSvc.CreateAsync(new ServerCluster
{
ClusterId = "flow-1", Name = "Flow test", Enterprise = "zb", Site = "dev",
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true,
CreatedBy = "test",
}, createdBy: "test", CancellationToken.None);
}
long draftId;
await using (var ctx = NewContext())
{
var genSvc = new GenerationService(ctx);
var draft = await genSvc.CreateDraftAsync("flow-1", "test", CancellationToken.None);
draftId = draft.GenerationId;
}
// 2. Add namespace + UNS + driver + equipment.
await using (var ctx = NewContext())
{
var nsSvc = new NamespaceService(ctx);
var unsSvc = new UnsService(ctx);
var drvSvc = new DriverInstanceService(ctx);
var eqSvc = new EquipmentService(ctx);
var ns = await nsSvc.AddAsync(draftId, "flow-1", "urn:flow:ns", NamespaceKind.Equipment, CancellationToken.None);
var area = await unsSvc.AddAreaAsync(draftId, "flow-1", "line-a", null, CancellationToken.None);
var line = await unsSvc.AddLineAsync(draftId, area.UnsAreaId, "cell-1", null, CancellationToken.None);
var driver = await drvSvc.AddAsync(draftId, "flow-1", ns.NamespaceId, "modbus", "ModbusTcp", "{}", CancellationToken.None);
await eqSvc.CreateAsync(draftId, new Equipment
{
EquipmentUuid = Guid.NewGuid(),
EquipmentId = string.Empty,
DriverInstanceId = driver.DriverInstanceId,
UnsLineId = line.UnsLineId,
Name = "eq-1",
MachineCode = "M001",
}, CancellationToken.None);
}
// 3. Validate — should be error-free.
await using (var ctx = NewContext())
{
var validationSvc = new DraftValidationService(ctx);
var errors = await validationSvc.ValidateAsync(draftId, CancellationToken.None);
errors.ShouldBeEmpty("draft with matched namespace/driver should validate clean");
}
// 4. Publish + verify status flipped.
await using (var ctx = NewContext())
{
var genSvc = new GenerationService(ctx);
await genSvc.PublishAsync("flow-1", draftId, "first publish", CancellationToken.None);
}
await using (var ctx = NewContext())
{
var status = await ctx.ConfigGenerations
.Where(g => g.GenerationId == draftId)
.Select(g => g.Status)
.FirstAsync();
status.ShouldBe(GenerationStatus.Published);
}
// 5. Rollback creates a new Published generation cloned from the target.
await using (var ctx = NewContext())
{
var genSvc = new GenerationService(ctx);
await genSvc.RollbackAsync("flow-1", draftId, "rollback test", CancellationToken.None);
}
await using (var ctx = NewContext())
{
var publishedCount = await ctx.ConfigGenerations
.CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Published);
publishedCount.ShouldBe(1, "rollback supersedes the prior publish with a new one");
var supersededCount = await ctx.ConfigGenerations
.CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Superseded);
supersededCount.ShouldBeGreaterThanOrEqualTo(1);
}
}
[Fact]
public async Task Validate_draft_surfaces_cross_cluster_namespace_binding_violation()
{
await using (var ctx = NewContext())
{
await new ClusterService(ctx).CreateAsync(new ServerCluster
{
ClusterId = "c-A", Name = "A", Enterprise = "zb", Site = "dev",
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
}, "t", CancellationToken.None);
await new ClusterService(ctx).CreateAsync(new ServerCluster
{
ClusterId = "c-B", Name = "B", Enterprise = "zb", Site = "dev",
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
}, "t", CancellationToken.None);
}
long draftId;
await using (var ctx = NewContext())
{
var draft = await new GenerationService(ctx).CreateDraftAsync("c-A", "t", CancellationToken.None);
draftId = draft.GenerationId;
}
await using (var ctx = NewContext())
{
// Namespace rooted in c-B, driver in c-A — decision #122 violation.
var ns = await new NamespaceService(ctx)
.AddAsync(draftId, "c-B", "urn:cross", NamespaceKind.Equipment, CancellationToken.None);
await new DriverInstanceService(ctx)
.AddAsync(draftId, "c-A", ns.NamespaceId, "drv", "ModbusTcp", "{}", CancellationToken.None);
}
await using (var ctx = NewContext())
{
var errors = await new DraftValidationService(ctx).ValidateAsync(draftId, CancellationToken.None);
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
}
}
}

View File

@@ -0,0 +1,153 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class CertTrustServiceTests : IDisposable
{
private readonly string _root;
public CertTrustServiceTests()
{
_root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs"));
Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs"));
}
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
private CertTrustService Service() => new(
Options.Create(new CertTrustOptions { PkiStoreRoot = _root }),
NullLogger<CertTrustService>.Instance);
private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der");
File.WriteAllBytes(path, cert.Export(X509ContentType.Cert));
return cert;
}
[Fact]
public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir()
{
var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A");
var rows = Service().ListRejected();
rows.Count.ShouldBe(1);
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
rows[0].Subject.ShouldContain("test-client-A");
rows[0].Store.ShouldBe(CertStoreKind.Rejected);
}
[Fact]
public void ListTrusted_is_separate_from_rejected()
{
WriteTestCert(CertStoreKind.Rejected, "rej");
WriteTestCert(CertStoreKind.Trusted, "trust");
var svc = Service();
svc.ListRejected().Count.ShouldBe(1);
svc.ListTrusted().Count.ShouldBe(1);
svc.ListRejected()[0].Subject.ShouldContain("rej");
svc.ListTrusted()[0].Subject.ShouldContain("trust");
}
[Fact]
public void TrustRejected_moves_file_from_rejected_to_trusted()
{
var c = WriteTestCert(CertStoreKind.Rejected, "promoteme");
var svc = Service();
svc.TrustRejected(c.Thumbprint).ShouldBeTrue();
svc.ListRejected().ShouldBeEmpty();
var trusted = svc.ListTrusted();
trusted.Count.ShouldBe(1);
trusted[0].Thumbprint.ShouldBe(c.Thumbprint);
}
[Fact]
public void TrustRejected_returns_false_when_thumbprint_not_in_rejected()
{
var svc = Service();
svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse();
}
[Fact]
public void DeleteRejected_removes_the_file()
{
var c = WriteTestCert(CertStoreKind.Rejected, "killme");
var svc = Service();
svc.DeleteRejected(c.Thumbprint).ShouldBeTrue();
svc.ListRejected().ShouldBeEmpty();
}
[Fact]
public void UntrustCert_removes_from_trusted_only()
{
var c = WriteTestCert(CertStoreKind.Trusted, "revoke");
var svc = Service();
svc.UntrustCert(c.Thumbprint).ShouldBeTrue();
svc.ListTrusted().ShouldBeEmpty();
}
[Fact]
public void Thumbprint_match_is_case_insensitive()
{
var c = WriteTestCert(CertStoreKind.Rejected, "case");
var svc = Service();
// X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often
// lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving.
svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue();
}
[Fact]
public void Missing_store_directories_produce_empty_lists_not_exceptions()
{
// Fresh root with no certs subfolder — service should tolerate a pristine install.
var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}");
try
{
var svc = new CertTrustService(
Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }),
NullLogger<CertTrustService>.Instance);
svc.ListRejected().ShouldBeEmpty();
svc.ListTrusted().ShouldBeEmpty();
}
finally
{
if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true);
}
}
[Fact]
public void Malformed_file_is_skipped_not_fatal()
{
// Drop junk bytes that don't parse as a cert into the rejected/certs directory. The
// service must skip it and still return the valid certs — one bad file can't take the
// whole management page offline.
File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert");
var c = WriteTestCert(CertStoreKind.Rejected, "valid");
var rows = Service().ListRejected();
rows.Count.ShouldBe(1);
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class ClusterNodeServiceTests
{
[Fact]
public void IsStale_NullLastSeen_Returns_True()
{
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: null);
ClusterNodeService.IsStale(node).ShouldBeTrue();
}
[Fact]
public void IsStale_RecentLastSeen_Returns_False()
{
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: DateTime.UtcNow.AddSeconds(-5));
ClusterNodeService.IsStale(node).ShouldBeFalse();
}
[Fact]
public void IsStale_Old_LastSeen_Returns_True()
{
var node = NewNode("A", RedundancyRole.Primary,
lastSeenAt: DateTime.UtcNow - ClusterNodeService.StaleThreshold - TimeSpan.FromSeconds(1));
ClusterNodeService.IsStale(node).ShouldBeTrue();
}
[Fact]
public async Task ListByClusterAsync_OrdersByServiceLevelBase_Descending_Then_NodeId()
{
using var ctx = NewContext();
ctx.ClusterNodes.AddRange(
NewNode("B-low", RedundancyRole.Secondary, serviceLevelBase: 150, clusterId: "c1"),
NewNode("A-high", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c1"),
NewNode("other-cluster", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c2"));
await ctx.SaveChangesAsync();
var svc = new ClusterNodeService(ctx);
var rows = await svc.ListByClusterAsync("c1", CancellationToken.None);
rows.Count.ShouldBe(2);
rows[0].NodeId.ShouldBe("A-high"); // higher ServiceLevelBase first
rows[1].NodeId.ShouldBe("B-low");
}
private static ClusterNode NewNode(
string nodeId,
RedundancyRole role,
DateTime? lastSeenAt = null,
int serviceLevelBase = 200,
string clusterId = "c1") => new()
{
NodeId = nodeId,
ClusterId = clusterId,
RedundancyRole = role,
Host = $"{nodeId}.example",
ApplicationUri = $"urn:{nodeId}",
ServiceLevelBase = (byte)serviceLevelBase,
LastSeenAt = lastSeenAt,
CreatedBy = "test",
};
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}

View File

@@ -0,0 +1,169 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class EquipmentCsvImporterTests
{
private const string Header =
"# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName";
[Fact]
public void EmptyFile_Throws()
{
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(""));
}
[Fact]
public void MissingVersionMarker_Throws()
{
var csv = "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x,x";
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
ex.Message.ShouldContain("# OtOpcUaCsv v1");
}
[Fact]
public void MissingRequiredColumn_Throws()
{
var csv = "# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentId,Name,UnsAreaName,UnsLineName\n" +
"z1,mc,sap,eq1,Name1,area,line";
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
ex.Message.ShouldContain("EquipmentUuid");
}
[Fact]
public void UnknownColumn_Throws()
{
var csv = Header + ",WeirdColumn\nz1,mc,sap,eq1,uu,Name1,area,line,value";
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
ex.Message.ShouldContain("WeirdColumn");
}
[Fact]
public void DuplicateColumn_Throws()
{
var csv = "# OtOpcUaCsv v1\n" +
"ZTag,ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
"z1,z1,mc,sap,eq,uu,Name,area,line";
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
}
[Fact]
public void ValidSingleRow_RoundTrips()
{
var csv = Header + "\nz-001,MC-1,SAP-1,eq-001,uuid-1,Oven-A,Warsaw,Line-1";
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.Count.ShouldBe(1);
result.RejectedRows.ShouldBeEmpty();
var row = result.AcceptedRows[0];
row.ZTag.ShouldBe("z-001");
row.MachineCode.ShouldBe("MC-1");
row.Name.ShouldBe("Oven-A");
row.UnsLineName.ShouldBe("Line-1");
}
[Fact]
public void OptionalColumns_Populated_WhenPresent()
{
var csv = "# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
"z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual";
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.Count.ShouldBe(1);
var row = result.AcceptedRows[0];
row.Manufacturer.ShouldBe("Siemens");
row.Model.ShouldBe("S7-1500");
row.SerialNumber.ShouldBe("SN123");
row.YearOfConstruction.ShouldBe("2023");
row.ManufacturerUri.ShouldBe("https://siemens.example");
}
[Fact]
public void BlankRequiredField_Rejects_Row()
{
var csv = Header + "\nz-1,MC,SAP,eq,uuid,,Warsaw,Line1"; // Name blank
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.ShouldBeEmpty();
result.RejectedRows.Count.ShouldBe(1);
result.RejectedRows[0].Reason.ShouldContain("Name");
}
[Fact]
public void DuplicateZTag_Rejects_SecondRow()
{
var csv = Header +
"\nz-1,MC1,SAP1,eq1,u1,N1,A,L1" +
"\nz-1,MC2,SAP2,eq2,u2,N2,A,L1";
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.Count.ShouldBe(1);
result.RejectedRows.Count.ShouldBe(1);
result.RejectedRows[0].Reason.ShouldContain("Duplicate ZTag");
}
[Fact]
public void QuotedField_With_CommaAndQuote_Parses_Correctly()
{
// RFC 4180: "" inside a quoted field is an escaped quote.
var csv = Header +
"\n\"z-1\",\"MC\",\"SAP,with,commas\",\"eq\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\"";
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.Count.ShouldBe(1);
result.AcceptedRows[0].SAPID.ShouldBe("SAP,with,commas");
result.AcceptedRows[0].Name.ShouldBe("Oven \"Ultra\"");
}
[Fact]
public void MismatchedColumnCount_Rejects_Row()
{
var csv = Header + "\nz-1,MC,SAP,eq,uuid,Name,Warsaw"; // missing UnsLineName cell
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.ShouldBeEmpty();
result.RejectedRows.Count.ShouldBe(1);
result.RejectedRows[0].Reason.ShouldContain("Column count");
}
[Fact]
public void BlankLines_BetweenRows_AreIgnored()
{
var csv = Header +
"\nz-1,MC,SAP,eq1,u1,N1,A,L1" +
"\n" +
"\nz-2,MC,SAP,eq2,u2,N2,A,L1";
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.Count.ShouldBe(2);
result.RejectedRows.ShouldBeEmpty();
}
[Fact]
public void Header_Constants_Match_Decision_117_and_139()
{
EquipmentCsvImporter.RequiredColumns.ShouldBe(
["ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
EquipmentCsvImporter.OptionalColumns.ShouldBe(
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
"YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri"]);
}
}

View File

@@ -0,0 +1,256 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class EquipmentImportBatchServiceTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
private readonly EquipmentImportBatchService _svc;
public EquipmentImportBatchServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"import-batch-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
_svc = new EquipmentImportBatchService(_db);
}
public void Dispose() => _db.Dispose();
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
{
ZTag = zTag,
MachineCode = "mc",
SAPID = $"sap-{zTag}",
EquipmentId = "eq-id",
EquipmentUuid = Guid.NewGuid().ToString(),
Name = name,
UnsAreaName = "area",
UnsLineName = "line",
};
[Fact]
public async Task CreateBatch_PopulatesId_AndTimestamp()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
batch.Id.ShouldNotBe(Guid.Empty);
batch.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
batch.RowsStaged.ShouldBe(0);
}
[Fact]
public async Task StageRows_AcceptedAndRejected_AllPersist()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
acceptedRows: [Row("z-1"), Row("z-2")],
rejectedRows: [new EquipmentCsvRowError(LineNumber: 5, Reason: "duplicate ZTag")],
CancellationToken.None);
var reloaded = await _db.EquipmentImportBatches.Include(b => b.Rows).FirstAsync(b => b.Id == batch.Id);
reloaded.RowsStaged.ShouldBe(3);
reloaded.RowsAccepted.ShouldBe(2);
reloaded.RowsRejected.ShouldBe(1);
reloaded.Rows.Count.ShouldBe(3);
reloaded.Rows.Count(r => r.IsAccepted).ShouldBe(2);
reloaded.Rows.Single(r => !r.IsAccepted).RejectReason.ShouldBe("duplicate ZTag");
}
[Fact]
public async Task DropBatch_RemovesBatch_AndCascades_Rows()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.DropBatchAsync(batch.Id, CancellationToken.None);
(await _db.EquipmentImportBatches.AnyAsync(b => b.Id == batch.Id)).ShouldBeFalse();
(await _db.EquipmentImportRows.AnyAsync(r => r.BatchId == batch.Id)).ShouldBeFalse("cascaded delete clears rows");
}
[Fact]
public async Task DropBatch_AfterFinalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, generationId: 1, driverInstanceIdForRows: "drv-1", unsLineIdForRows: "line-1", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.DropBatchAsync(batch.Id, CancellationToken.None));
}
[Fact]
public async Task Finalise_AcceptedRows_BecomeEquipment()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
[Row("z-1", name: "alpha"), Row("z-2", name: "beta")],
rejectedRows: [new EquipmentCsvRowError(1, "rejected")],
CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 5, "drv-modbus", "line-warsaw", CancellationToken.None);
var equipment = await _db.Equipment.Where(e => e.GenerationId == 5).ToListAsync();
equipment.Count.ShouldBe(2);
equipment.Select(e => e.Name).ShouldBe(["alpha", "beta"], ignoreOrder: true);
equipment.All(e => e.DriverInstanceId == "drv-modbus").ShouldBeTrue();
equipment.All(e => e.UnsLineId == "line-warsaw").ShouldBeTrue();
var reloaded = await _db.EquipmentImportBatches.FirstAsync(b => b.Id == batch.Id);
reloaded.FinalisedAtUtc.ShouldNotBeNull();
}
[Fact]
public async Task Finalise_Twice_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.FinaliseBatchAsync(batch.Id, 2, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Finalise_MissingBatch_Throws()
{
await Should.ThrowAsync<ImportBatchNotFoundException>(
() => _svc.FinaliseBatchAsync(Guid.NewGuid(), 1, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Stage_After_Finalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.StageRowsAsync(batch.Id, [Row("z-2")], [], CancellationToken.None));
}
[Fact]
public async Task ListByUser_FiltersByCreator_AndFinalised()
{
var a = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var b = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
await _svc.StageRowsAsync(a.Id, [Row("z-a")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(a.Id, 1, "d", "l", CancellationToken.None);
_ = b;
var aliceOpen = await _svc.ListByUserAsync("alice", includeFinalised: false, CancellationToken.None);
aliceOpen.ShouldBeEmpty("alice's only batch is finalised");
var aliceAll = await _svc.ListByUserAsync("alice", includeFinalised: true, CancellationToken.None);
aliceAll.Count.ShouldBe(1);
var bobOpen = await _svc.ListByUserAsync("bob", includeFinalised: false, CancellationToken.None);
bobOpen.Count.ShouldBe(1);
}
[Fact]
public async Task DropBatch_Unknown_IsNoOp()
{
await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None);
// no throw
}
[Fact]
public async Task FinaliseBatch_Creates_ExternalIdReservations_ForZTagAndSAPID()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-new-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
var active = await _db.ExternalIdReservations.AsNoTracking()
.Where(r => r.ReleasedAt == null)
.ToListAsync();
active.Count.ShouldBe(2);
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag && r.Value == "z-new-1");
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID && r.Value == "sap-z-new-1");
}
[Fact]
public async Task FinaliseBatch_SameEquipmentUuid_ReusesExistingReservation()
{
var batch1 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var sharedUuid = Guid.NewGuid();
var row = new EquipmentCsvRow
{
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
EquipmentId = "eq-1", EquipmentUuid = sharedUuid.ToString(),
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
};
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch1.Id, 1, "drv", "line", CancellationToken.None);
var countAfterFirst = _db.ExternalIdReservations.Count(r => r.ReleasedAt == null);
// Second finalise with same EquipmentUuid + same ZTag — should NOT create a duplicate.
var batch2 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch2.Id, [row], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch2.Id, 2, "drv", "line", CancellationToken.None);
_db.ExternalIdReservations.Count(r => r.ReleasedAt == null).ShouldBe(countAfterFirst);
}
[Fact]
public async Task FinaliseBatch_DifferentEquipmentUuid_SameZTag_Throws_Conflict()
{
var batchA = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var rowA = new EquipmentCsvRow
{
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
EquipmentId = "eq-a", EquipmentUuid = Guid.NewGuid().ToString(),
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
var rowB = new EquipmentCsvRow
{
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
EquipmentId = "eq-b", EquipmentUuid = Guid.NewGuid().ToString(),
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
var ex = await Should.ThrowAsync<ExternalIdReservationConflictException>(() =>
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
ex.Message.ShouldContain("z-collide");
// Second finalise must have rolled back — no partial Equipment row for batch B.
var equipmentB = await _db.Equipment.AsNoTracking()
.Where(e => e.EquipmentId == "eq-b")
.ToListAsync();
equipmentB.ShouldBeEmpty();
}
[Fact]
public async Task FinaliseBatch_EmptyZTagAndSAPID_SkipsReservation()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var row = new EquipmentCsvRow
{
ZTag = "", MachineCode = "mc", SAPID = "",
EquipmentId = "eq-nil", EquipmentUuid = Guid.NewGuid().ToString(),
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
_db.ExternalIdReservations.Count().ShouldBe(0);
}
}

View File

@@ -0,0 +1,214 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Integration")]
public sealed class FleetStatusPollerTests : IDisposable
{
private const string DefaultServer = "localhost,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
private readonly string _databaseName = $"OtOpcUaPollerTest_{Guid.NewGuid():N}";
private readonly string _connectionString;
private readonly ServiceProvider _sp;
public FleetStatusPollerTests()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
_connectionString =
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
var services = new ServiceCollection();
services.AddLogging();
services.AddSignalR();
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
_sp = services.BuildServiceProvider();
using var scope = _sp.CreateScope();
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
}
public void Dispose()
{
_sp.Dispose();
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString)
{ InitialCatalog = "master" }.ConnectionString);
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();
}
[Fact]
public async Task Poller_detects_new_apply_state_and_pushes_to_fleet_hub()
{
// Seed a cluster + node + credential + generation + apply state.
using (var scope = _sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "p-1", Name = "Poll test", Enterprise = "zb", Site = "dev",
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
});
db.ClusterNodes.Add(new ClusterNode
{
NodeId = "p-1-a", ClusterId = "p-1", RedundancyRole = RedundancyRole.Primary,
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
ApplicationUri = "urn:p1:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
});
var gen = new ConfigGeneration
{
ClusterId = "p-1", Status = GenerationStatus.Published, CreatedBy = "t",
PublishedBy = "t", PublishedAt = DateTime.UtcNow,
};
db.ConfigGenerations.Add(gen);
await db.SaveChangesAsync();
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
{
NodeId = "p-1-a", CurrentGenerationId = gen.GenerationId,
LastAppliedStatus = NodeApplyStatus.Applied,
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
// Recording hub contexts — capture what would be pushed to clients.
var recorder = new RecordingHubClients();
var fleetHub = new RecordingHubContext<FleetStatusHub>(recorder);
var alertHub = new RecordingHubContext<AlertHub>(new RecordingHubClients());
var poller = new FleetStatusPoller(
_sp.GetRequiredService<IServiceScopeFactory>(),
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
await poller.PollOnceAsync(CancellationToken.None);
var match = recorder.SentMessages.FirstOrDefault(m =>
m.Method == "NodeStateChanged" &&
m.Args.Length > 0 &&
m.Args[0] is NodeStateChangedMessage msg &&
msg.NodeId == "p-1-a");
match.ShouldNotBeNull("poller should have pushed a NodeStateChanged for p-1-a");
}
[Fact]
public async Task Poller_raises_alert_on_transition_into_Failed()
{
using (var scope = _sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "p-2", Name = "Fail test", Enterprise = "zb", Site = "dev",
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
});
db.ClusterNodes.Add(new ClusterNode
{
NodeId = "p-2-a", ClusterId = "p-2", RedundancyRole = RedundancyRole.Primary,
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
ApplicationUri = "urn:p2:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
});
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
{
NodeId = "p-2-a",
LastAppliedStatus = NodeApplyStatus.Failed,
LastAppliedError = "simulated",
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
var alerts = new RecordingHubClients();
var alertHub = new RecordingHubContext<AlertHub>(alerts);
var fleetHub = new RecordingHubContext<FleetStatusHub>(new RecordingHubClients());
var poller = new FleetStatusPoller(
_sp.GetRequiredService<IServiceScopeFactory>(),
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
await poller.PollOnceAsync(CancellationToken.None);
var alertMatch = alerts.SentMessages.FirstOrDefault(m =>
m.Method == "AlertRaised" &&
m.Args.Length > 0 &&
m.Args[0] is AlertMessage alert && alert.NodeId == "p-2-a" && alert.Severity == "error");
alertMatch.ShouldNotBeNull("poller should have raised AlertRaised for p-2-a");
}
[Fact]
public async Task Poller_pushes_ResilienceStatusChanged_on_delta()
{
// Phase 6.1 Stream E.2 — DriverInstanceResilienceStatus row changes should surface
// on the fleet hub so /hosts updates without waiting for the 10s poll.
using (var scope = _sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
db.DriverInstanceResilienceStatuses.Add(new DriverInstanceResilienceStatus
{
DriverInstanceId = "drv-1", HostName = "plc.example.com",
ConsecutiveFailures = 2, CurrentBulkheadDepth = 1,
LastSampledUtc = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
var recorder = new RecordingHubClients();
var fleetHub = new RecordingHubContext<FleetStatusHub>(recorder);
var alertHub = new RecordingHubContext<AlertHub>(new RecordingHubClients());
var poller = new FleetStatusPoller(
_sp.GetRequiredService<IServiceScopeFactory>(),
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
await poller.PollOnceAsync(CancellationToken.None);
var match = recorder.SentMessages.FirstOrDefault(m =>
m.Method == "ResilienceStatusChanged" &&
m.Args.Length > 0 &&
m.Args[0] is ResilienceStatusChangedMessage r &&
r.DriverInstanceId == "drv-1" && r.HostName == "plc.example.com");
match.ShouldNotBeNull("poller should have pushed ResilienceStatusChanged on first observation");
// Same snapshot on the next tick — should NOT push again (delta-only push).
recorder.SentMessages.Clear();
await poller.PollOnceAsync(CancellationToken.None);
recorder.SentMessages.Any(m => m.Method == "ResilienceStatusChanged")
.ShouldBeFalse("unchanged snapshot must not fire another push");
// Mutate the row — delta should fire again.
using (var scope = _sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var row = await db.DriverInstanceResilienceStatuses.SingleAsync();
row.ConsecutiveFailures = 5;
row.LastCircuitBreakerOpenUtc = DateTime.UtcNow;
await db.SaveChangesAsync();
}
await poller.PollOnceAsync(CancellationToken.None);
var mutatedMatch = recorder.SentMessages.FirstOrDefault(m =>
m.Method == "ResilienceStatusChanged" &&
m.Args.Length > 0 &&
m.Args[0] is ResilienceStatusChangedMessage r2 && r2.ConsecutiveFailures == 5);
mutatedMatch.ShouldNotBeNull("mutated row should produce a second ResilienceStatusChanged");
}
}

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class FocasDriverDetailServiceTests
{
[Fact]
public async Task GetAsync_returns_null_for_unknown_instance()
{
using var ctx = NewContext();
var svc = new FocasDriverDetailService(ctx);
(await svc.GetAsync("missing", CancellationToken.None)).ShouldBeNull();
}
[Fact]
public async Task GetAsync_returns_null_for_non_focas_driver_type()
{
using var ctx = NewContext();
ctx.DriverInstances.Add(NewInstance("drv-modbus", "ModbusTcp", "{}"));
await ctx.SaveChangesAsync();
var svc = new FocasDriverDetailService(ctx);
(await svc.GetAsync("drv-modbus", CancellationToken.None)).ShouldBeNull();
}
[Fact]
public async Task GetAsync_parses_devices_tags_and_alarm_projection()
{
using var ctx = NewContext();
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", """
{
"Devices": [
{ "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i" }
],
"Tags": [
{ "Name": "Mode", "DeviceHostAddress": "focas://10.20.30.40:8193",
"Address": "PARAM:3402", "DataType": "Int32", "Writable": false }
],
"AlarmProjection": { "Enabled": true, "PollInterval": "00:00:05" },
"HandleRecycle": { "Enabled": true, "Interval": "01:00:00" }
}
"""));
await ctx.SaveChangesAsync();
var svc = new FocasDriverDetailService(ctx);
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
detail.ShouldNotBeNull();
detail.ParseError.ShouldBeNull();
detail.Config.ShouldNotBeNull();
detail.Config.Devices!.Single().HostAddress.ShouldBe("focas://10.20.30.40:8193");
detail.Config.Devices!.Single().Series.ShouldBe("ThirtyOne_i");
detail.Config.Tags!.Single().Name.ShouldBe("Mode");
detail.Config.AlarmProjection!.Enabled.ShouldBeTrue();
detail.Config.HandleRecycle!.Enabled.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_surfaces_parse_error_for_malformed_json()
{
using var ctx = NewContext();
ctx.DriverInstances.Add(NewInstance("drv-bad", "Focas", "{ not-valid-json"));
await ctx.SaveChangesAsync();
var svc = new FocasDriverDetailService(ctx);
var detail = await svc.GetAsync("drv-bad", CancellationToken.None);
detail.ShouldNotBeNull();
detail.ParseError.ShouldNotBeNull();
detail.Config.ShouldBeNull();
}
[Fact]
public async Task GetAsync_joins_host_status_rows_for_the_instance()
{
using var ctx = NewContext();
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", "{}"));
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-A",
DriverInstanceId = "drv-focas",
HostName = "focas://10.0.0.1:8193",
State = DriverHostState.Running,
StateChangedUtc = DateTime.UtcNow.AddMinutes(-5),
LastSeenUtc = DateTime.UtcNow.AddSeconds(-3),
});
await ctx.SaveChangesAsync();
var svc = new FocasDriverDetailService(ctx);
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
detail.ShouldNotBeNull();
detail.HostStatuses.Count.ShouldBe(1);
detail.HostStatuses[0].HostName.ShouldBe("focas://10.0.0.1:8193");
detail.HostStatuses[0].State.ShouldBe("Running");
}
[Fact]
public async Task GetAsync_picks_latest_generation_when_multiple_rows_exist()
{
using var ctx = NewContext();
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", "{\"Tags\":[]}", generationId: 1));
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", """{"Tags":[{"Name":"later"}]}""", generationId: 2));
await ctx.SaveChangesAsync();
var svc = new FocasDriverDetailService(ctx);
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
detail.ShouldNotBeNull();
detail.Config!.Tags!.Single().Name.ShouldBe("later");
}
private static DriverInstance NewInstance(
string driverInstanceId, string driverType, string driverConfigJson, long generationId = 1) => new()
{
GenerationId = generationId,
DriverInstanceId = driverInstanceId,
ClusterId = "cluster-1",
NamespaceId = "ns-1",
Name = driverInstanceId,
DriverType = driverType,
DriverConfig = driverConfigJson,
};
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}

View File

@@ -0,0 +1,45 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Deterministic unit tests for the LDAP input-sanitization and DN-parsing helpers. Live LDAP
/// bind against the GLAuth dev instance is covered by the admin-browser smoke path, not here,
/// because unit runs must not depend on a running external service.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LdapAuthServiceTests
{
private static string EscapeLdapFilter(string input) =>
(string)typeof(LdapAuthService)
.GetMethod("EscapeLdapFilter", BindingFlags.NonPublic | BindingFlags.Static)!
.Invoke(null, [input])!;
private static string ExtractFirstRdnValue(string dn) =>
(string)typeof(LdapAuthService)
.GetMethod("ExtractFirstRdnValue", BindingFlags.NonPublic | BindingFlags.Static)!
.Invoke(null, [dn])!;
[Theory]
[InlineData("alice", "alice")]
[InlineData("a(b)c", "a\\28b\\29c")]
[InlineData("wildcard*", "wildcard\\2a")]
[InlineData("back\\slash", "back\\5cslash")]
public void Escape_filter_replaces_control_chars(string input, string expected)
{
EscapeLdapFilter(input).ShouldBe(expected);
}
[Theory]
[InlineData("ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local", "ReadOnly")]
[InlineData("cn=admin,dc=corp,dc=com", "admin")]
[InlineData("ReadOnly", "ReadOnly")] // no '=' → pass through
[InlineData("ou=OnlySegment", "OnlySegment")]
public void Extract_first_RDN_strips_the_first_attribute_value(string dn, string expected)
{
ExtractFirstRdnValue(dn).ShouldBe(expected);
}
}

View File

@@ -0,0 +1,77 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped when
/// the port is unreachable so the test suite stays portable. Verifies the bind path —
/// group/role resolution is covered deterministically by <see cref="RoleMapperTests"/>,
/// <see cref="LdapAuthServiceTests"/>, and varies per directory (GLAuth, OpenLDAP, AD emit
/// <c>memberOf</c> differently; the service has a DN-based fallback for the GLAuth case).
/// </summary>
[Trait("Category", "LiveLdap")]
public sealed class LdapLiveBindTests
{
private static bool GlauthReachable()
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync("localhost", 3893);
return task.Wait(TimeSpan.FromSeconds(1));
}
catch { return false; }
}
private static LdapAuthService NewService() => new(Options.Create(new LdapOptions
{
Server = "localhost",
Port = 3893,
UseTls = false,
AllowInsecureLdap = true,
SearchBase = "dc=lmxopcua,dc=local",
ServiceAccountDn = "", // direct-bind: GLAuth's nameformat=cn + baseDN means user DN is cn={name},{baseDN}
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
["WriteOperate"] = "ConfigEditor",
["AlarmAck"] = "FleetAdmin",
},
}), NullLogger<LdapAuthService>.Instance);
[Fact]
public async Task Valid_credentials_bind_successfully()
{
if (!GlauthReachable()) return;
var result = await NewService().AuthenticateAsync("readonly", "readonly123");
result.Success.ShouldBeTrue(result.Error);
result.Username.ShouldBe("readonly");
}
[Fact]
public async Task Wrong_password_fails_bind()
{
if (!GlauthReachable()) return;
var result = await NewService().AuthenticateAsync("readonly", "wrong-pw");
result.Success.ShouldBeFalse();
result.Error.ShouldContain("Invalid");
}
[Fact]
public async Task Empty_username_is_rejected_before_hitting_the_directory()
{
// Doesn't need GLAuth — pre-flight validation in the service.
var result = await NewService().AuthenticateAsync("", "anything");
result.Success.ShouldBeFalse();
result.Error.ShouldContain("required", Case.Insensitive);
}
}

View File

@@ -0,0 +1,136 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// #145 Admin UI: smoke coverage for the ModbusOptionsEditor view model. The Blazor
/// component itself is exercised in browser-runtime tests; this fixture pins the default
/// values the form initialises to so a regression that flips an unedited row to a
/// non-default value gets caught.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusOptionsViewModelTests
{
[Fact]
public void DriversTab_Serialized_Defaults_RoundTrip_Through_Factory()
{
// #147 — the form's SaveAsync serialises ModbusOptionsViewModel to the JSON DTO
// shape ModbusDriverFactoryExtensions consumes. This test pins the round-trip:
// unedited form → JSON → driver instance → options match defaults.
var vm = new ModbusOptionsEditor.ModbusOptionsViewModel();
var json = SerializeForRoundTrip(vm);
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-roundtrip", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Host.ShouldBe(vm.Host);
opts.Port.ShouldBe(vm.Port);
opts.UnitId.ShouldBe(vm.UnitId);
opts.Family.ShouldBe(vm.Family);
opts.MelsecSubFamily.ShouldBe(vm.MelsecSubFamily);
opts.KeepAlive.Enabled.ShouldBe(vm.KeepAliveEnabled);
opts.MaxRegistersPerRead.ShouldBe((ushort)vm.MaxRegistersPerRead);
opts.MaxCoilsPerRead.ShouldBe((ushort)vm.MaxCoilsPerRead);
opts.MaxReadGap.ShouldBe((ushort)vm.MaxReadGap);
opts.WriteOnChangeOnly.ShouldBe(vm.WriteOnChangeOnly);
}
[Fact]
public void DriversTab_Serializes_Edited_Values_Correctly()
{
// Sanity: changing a few fields in the view model produces a JSON the factory
// accepts and the resulting driver carries the edited values.
var vm = new ModbusOptionsEditor.ModbusOptionsViewModel
{
Host = "10.5.5.5",
Port = 1502,
UnitId = 7,
Family = ModbusFamily.DL205,
MaxReadGap = 12,
WriteOnChangeOnly = true,
};
var json = SerializeForRoundTrip(vm);
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-edited", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Host.ShouldBe("10.5.5.5");
opts.Port.ShouldBe(1502);
opts.UnitId.ShouldBe((byte)7);
opts.Family.ShouldBe(ModbusFamily.DL205);
opts.MaxReadGap.ShouldBe((ushort)12);
opts.WriteOnChangeOnly.ShouldBeTrue();
}
/// <summary>
/// Mirror of DriversTab.razor's SerializeModbusOptions — kept here so the test
/// doesn't have to reach through Blazor component plumbing to invoke it. If the
/// component method signature drifts, update both.
/// </summary>
private static string SerializeForRoundTrip(ModbusOptionsEditor.ModbusOptionsViewModel m) =>
System.Text.Json.JsonSerializer.Serialize(new
{
host = m.Host,
port = m.Port,
unitId = m.UnitId,
family = m.Family.ToString(),
melsecSubFamily = m.MelsecSubFamily.ToString(),
keepAlive = new
{
enabled = m.KeepAliveEnabled,
timeMs = m.KeepAliveTimeSec * 1000,
intervalMs = m.KeepAliveIntervalSec * 1000,
retryCount = m.KeepAliveRetryCount,
},
reconnect = new
{
initialDelayMs = m.ReconnectInitialDelayMs,
maxDelayMs = m.ReconnectMaxDelayMs,
backoffMultiplier = m.ReconnectBackoffMultiplier,
},
maxRegistersPerRead = m.MaxRegistersPerRead,
maxRegistersPerWrite = m.MaxRegistersPerWrite,
maxCoilsPerRead = m.MaxCoilsPerRead,
maxReadGap = m.MaxReadGap,
useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites,
useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites,
writeOnChangeOnly = m.WriteOnChangeOnly,
tags = Array.Empty<object>(),
});
[Fact]
public void Defaults_Match_DriverOption_Defaults()
{
var vm = new ModbusOptionsEditor.ModbusOptionsViewModel();
var driverDefault = new ModbusDriverOptions();
vm.Host.ShouldBe(driverDefault.Host);
vm.Port.ShouldBe(driverDefault.Port);
vm.UnitId.ShouldBe(driverDefault.UnitId);
vm.Family.ShouldBe(driverDefault.Family);
vm.MelsecSubFamily.ShouldBe(driverDefault.MelsecSubFamily);
vm.KeepAliveEnabled.ShouldBe(driverDefault.KeepAlive.Enabled);
vm.KeepAliveTimeSec.ShouldBe((int)driverDefault.KeepAlive.Time.TotalSeconds);
vm.KeepAliveIntervalSec.ShouldBe((int)driverDefault.KeepAlive.Interval.TotalSeconds);
vm.KeepAliveRetryCount.ShouldBe(driverDefault.KeepAlive.RetryCount);
vm.ReconnectInitialDelayMs.ShouldBe((int)driverDefault.Reconnect.InitialDelay.TotalMilliseconds);
vm.ReconnectMaxDelayMs.ShouldBe((int)driverDefault.Reconnect.MaxDelay.TotalMilliseconds);
vm.ReconnectBackoffMultiplier.ShouldBe(driverDefault.Reconnect.BackoffMultiplier);
vm.MaxRegistersPerRead.ShouldBe(driverDefault.MaxRegistersPerRead);
vm.MaxRegistersPerWrite.ShouldBe(driverDefault.MaxRegistersPerWrite);
vm.MaxCoilsPerRead.ShouldBe(driverDefault.MaxCoilsPerRead);
vm.MaxReadGap.ShouldBe(driverDefault.MaxReadGap);
vm.UseFC15ForSingleCoilWrites.ShouldBe(driverDefault.UseFC15ForSingleCoilWrites);
vm.UseFC16ForSingleRegisterWrites.ShouldBe(driverDefault.UseFC16ForSingleRegisterWrites);
vm.WriteOnChangeOnly.ShouldBe(driverDefault.WriteOnChangeOnly);
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class PermissionProbeServiceTests
{
[Fact]
public async Task Probe_Grants_When_ClusterLevelRow_CoversRequiredFlag()
{
using var ctx = NewContext();
SeedAcl(ctx, gen: 1, cluster: "c1",
scopeKind: NodeAclScopeKind.Cluster, scopeId: null,
group: "cn=operators", flags: NodePermissions.Browse | NodePermissions.Read);
var svc = new PermissionProbeService(ctx);
var result = await svc.ProbeAsync(
generationId: 1,
ldapGroup: "cn=operators",
scope: new NodeScope { ClusterId = "c1", NamespaceId = "ns-1", Kind = NodeHierarchyKind.Equipment },
required: NodePermissions.Read,
CancellationToken.None);
result.Granted.ShouldBeTrue();
result.Matches.Count.ShouldBe(1);
result.Matches[0].LdapGroup.ShouldBe("cn=operators");
result.Matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
}
[Fact]
public async Task Probe_Denies_When_NoGroupMatches()
{
using var ctx = NewContext();
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
var svc = new PermissionProbeService(ctx);
var result = await svc.ProbeAsync(1, "cn=random-group",
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
NodePermissions.Read, CancellationToken.None);
result.Granted.ShouldBeFalse();
result.Matches.ShouldBeEmpty();
result.Effective.ShouldBe(NodePermissions.None);
}
[Fact]
public async Task Probe_Denies_When_Effective_Missing_RequiredFlag()
{
using var ctx = NewContext();
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Browse | NodePermissions.Read);
var svc = new PermissionProbeService(ctx);
var result = await svc.ProbeAsync(1, "cn=operators",
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
required: NodePermissions.WriteOperate,
CancellationToken.None);
result.Granted.ShouldBeFalse();
result.Effective.ShouldBe(NodePermissions.Browse | NodePermissions.Read);
}
[Fact]
public async Task Probe_Ignores_Rows_From_OtherClusters()
{
using var ctx = NewContext();
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
SeedAcl(ctx, 1, "c2", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
var svc = new PermissionProbeService(ctx);
var c1Result = await svc.ProbeAsync(1, "cn=operators",
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
NodePermissions.WriteOperate, CancellationToken.None);
c1Result.Granted.ShouldBeFalse("c2's WriteOperate grant must NOT leak into c1's probe");
}
[Fact]
public async Task Probe_UsesOnlyRows_From_Specified_Generation()
{
using var ctx = NewContext();
SeedAcl(ctx, gen: 1, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
SeedAcl(ctx, gen: 2, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
var svc = new PermissionProbeService(ctx);
var gen1 = await svc.ProbeAsync(1, "cn=operators",
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
NodePermissions.WriteOperate, CancellationToken.None);
var gen2 = await svc.ProbeAsync(2, "cn=operators",
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
NodePermissions.WriteOperate, CancellationToken.None);
gen1.Granted.ShouldBeFalse();
gen2.Granted.ShouldBeTrue();
}
private static void SeedAcl(
OtOpcUaConfigDbContext ctx, long gen, string cluster,
NodeAclScopeKind scopeKind, string? scopeId, string group, NodePermissions flags)
{
ctx.NodeAcls.Add(new NodeAcl
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = $"acl-{Guid.NewGuid():N}"[..16],
GenerationId = gen,
ClusterId = cluster,
LdapGroup = group,
ScopeKind = scopeKind,
ScopeId = scopeId,
PermissionFlags = flags,
});
ctx.SaveChanges();
}
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}

View File

@@ -0,0 +1,196 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Admin-side services shipped in Phase 7 Stream F — draft CRUD for scripts + virtual
/// tags + scripted alarms, the pre-publish test harness, and the historian
/// diagnostics façade.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Phase7ServicesTests
{
private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "")
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"phase7-{test}-{Guid.NewGuid():N}")
.Options;
return new OtOpcUaConfigDbContext(options);
}
[Fact]
public async Task ScriptService_AddAsync_generates_logical_id_and_hash()
{
using var db = NewDb();
var svc = new ScriptService(db);
var s = await svc.AddAsync(5, "line-rate", "return ctx.GetTag(\"a\").Value;", default);
s.ScriptId.ShouldStartWith("scr-");
s.GenerationId.ShouldBe(5);
s.SourceHash.Length.ShouldBe(64);
(await svc.ListAsync(5, default)).Count.ShouldBe(1);
}
[Fact]
public async Task ScriptService_UpdateAsync_recomputes_hash_on_source_change()
{
using var db = NewDb();
var svc = new ScriptService(db);
var s = await svc.AddAsync(5, "s", "return 1;", default);
var hashBefore = s.SourceHash;
var updated = await svc.UpdateAsync(5, s.ScriptId, "s", "return 2;", default);
updated.SourceHash.ShouldNotBe(hashBefore);
}
[Fact]
public async Task ScriptService_UpdateAsync_same_source_same_hash()
{
using var db = NewDb();
var svc = new ScriptService(db);
var s = await svc.AddAsync(5, "s", "return 1;", default);
var updated = await svc.UpdateAsync(5, s.ScriptId, "renamed", "return 1;", default);
updated.SourceHash.ShouldBe(s.SourceHash, "source unchanged → hash unchanged → compile cache hit preserved");
}
[Fact]
public async Task ScriptService_DeleteAsync_is_idempotent()
{
using var db = NewDb();
var svc = new ScriptService(db);
await Should.NotThrowAsync(() => svc.DeleteAsync(5, "nonexistent", default));
}
[Fact]
public async Task VirtualTagService_round_trips_trigger_flags()
{
using var db = NewDb();
var svc = new VirtualTagService(db);
var v = await svc.AddAsync(7, "eq-1", "LineRate", "Float32", "scr-1",
changeTriggered: true, timerIntervalMs: 1000, historize: true, default);
v.ChangeTriggered.ShouldBeTrue();
v.TimerIntervalMs.ShouldBe(1000);
v.Historize.ShouldBeTrue();
v.Enabled.ShouldBeTrue();
(await svc.ListAsync(7, default)).Single().VirtualTagId.ShouldBe(v.VirtualTagId);
}
[Fact]
public async Task VirtualTagService_update_enabled_toggles_flag()
{
using var db = NewDb();
var svc = new VirtualTagService(db);
var v = await svc.AddAsync(7, "eq-1", "N", "Int32", "scr-1", true, null, false, default);
var disabled = await svc.UpdateEnabledAsync(7, v.VirtualTagId, false, default);
disabled.Enabled.ShouldBeFalse();
}
[Fact]
public async Task ScriptedAlarmService_defaults_HistorizeToAveva_true_per_plan_decision_15()
{
using var db = NewDb();
var svc = new ScriptedAlarmService(db);
var a = await svc.AddAsync(9, "eq-1", "HighTemp", "LimitAlarm", severity: 800,
messageTemplate: "{Temp} too high", predicateScriptId: "scr-9",
historizeToAveva: true, retain: true, default);
a.HistorizeToAveva.ShouldBeTrue();
a.Severity.ShouldBe(800);
a.ScriptedAlarmId.ShouldStartWith("sal-");
}
[Fact]
public async Task ScriptTestHarness_runs_successful_script_and_captures_writes()
{
var harness = new ScriptTestHarnessService();
var source = """
ctx.SetVirtualTag("Out", 42);
return ctx.GetTag("In").Value;
""";
var inputs = new Dictionary<string, DataValueSnapshot>
{
["In"] = new(123, 0u, DateTime.UtcNow, DateTime.UtcNow),
};
var result = await harness.RunVirtualTagAsync(source, inputs, default);
result.Outcome.ShouldBe(ScriptTestOutcome.Success);
result.Output.ShouldBe(123);
result.Writes["Out"].ShouldBe(42);
}
[Fact]
public async Task ScriptTestHarness_rejects_missing_synthetic_input()
{
var harness = new ScriptTestHarnessService();
var source = """return ctx.GetTag("A").Value;""";
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
result.Outcome.ShouldBe(ScriptTestOutcome.MissingInputs);
result.Errors[0].ShouldContain("A");
}
[Fact]
public async Task ScriptTestHarness_rejects_extra_synthetic_input_not_referenced_by_script()
{
var harness = new ScriptTestHarnessService();
var source = """return 1;"""; // no GetTag calls
var inputs = new Dictionary<string, DataValueSnapshot>
{
["Unexpected"] = new(0, 0u, DateTime.UtcNow, DateTime.UtcNow),
};
var result = await harness.RunVirtualTagAsync(source, inputs, default);
result.Outcome.ShouldBe(ScriptTestOutcome.UnknownInputs);
result.Errors[0].ShouldContain("Unexpected");
}
[Fact]
public async Task ScriptTestHarness_rejects_non_literal_path()
{
var harness = new ScriptTestHarnessService();
var source = """
var p = "A";
return ctx.GetTag(p).Value;
""";
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
result.Outcome.ShouldBe(ScriptTestOutcome.DependencyRejected);
result.Errors.ShouldNotBeEmpty();
}
[Fact]
public async Task ScriptTestHarness_surfaces_compile_error_as_Threw()
{
var harness = new ScriptTestHarnessService();
var source = "this is not valid C#;";
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
result.Outcome.ShouldBe(ScriptTestOutcome.Threw);
}
[Fact]
public void HistorianDiagnosticsService_reports_Disabled_for_null_sink()
{
var diag = new HistorianDiagnosticsService(NullAlarmHistorianSink.Instance);
diag.GetStatus().DrainState.ShouldBe(HistorianDrainState.Disabled);
diag.TryRetryDeadLettered().ShouldBe(0);
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Minimal in-memory <see cref="IHubContext{THub}"/> that captures SendAsync invocations for
/// assertion. Only the methods the <c>FleetStatusPoller</c> actually calls are implemented —
/// other interface surface throws to fail fast if the poller evolves new dependencies.
/// </summary>
public sealed class RecordingHubContext<THub> : IHubContext<THub> where THub : Hub
{
public RecordingHubContext(RecordingHubClients clients) => Clients = clients;
public IHubClients Clients { get; }
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class RecordingHubClients : IHubClients
{
public readonly List<RecordedMessage> SentMessages = [];
public IClientProxy All => NotUsed();
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NotUsed();
public IClientProxy Client(string connectionId) => NotUsed();
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NotUsed();
public IClientProxy Group(string groupName) => new RecordingClientProxy(groupName, SentMessages);
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NotUsed();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NotUsed();
public IClientProxy User(string userId) => NotUsed();
public IClientProxy Users(IReadOnlyList<string> userIds) => NotUsed();
private static IClientProxy NotUsed() => throw new NotImplementedException("not used by FleetStatusPoller");
}
public sealed class RecordingClientProxy(string target, List<RecordedMessage> sink) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
sink.Add(new RecordedMessage(target, method, args));
return Task.CompletedTask;
}
}
public sealed record RecordedMessage(string Target, string Method, object?[] Args);

View File

@@ -0,0 +1,70 @@
using System.Diagnostics.Metrics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class RedundancyMetricsTests
{
[Fact]
public void RecordRoleTransition_Increments_Counter_WithExpectedTags()
{
using var metrics = new RedundancyMetrics();
using var listener = new MeterListener();
var observed = new List<(long Value, Dictionary<string, object?> Tags)>();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == RedundancyMetrics.MeterName &&
instrument.Name == "otopcua.redundancy.role_transition")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
var dict = new Dictionary<string, object?>();
foreach (var tag in tags) dict[tag.Key] = tag.Value;
observed.Add((value, dict));
});
listener.Start();
metrics.RecordRoleTransition("c1", "node-a", "Primary", "Secondary");
observed.Count.ShouldBe(1);
observed[0].Value.ShouldBe(1);
observed[0].Tags["cluster.id"].ShouldBe("c1");
observed[0].Tags["node.id"].ShouldBe("node-a");
observed[0].Tags["from_role"].ShouldBe("Primary");
observed[0].Tags["to_role"].ShouldBe("Secondary");
}
[Fact]
public void SetClusterCounts_Observed_Via_ObservableGauges()
{
using var metrics = new RedundancyMetrics();
metrics.SetClusterCounts("c1", primary: 1, secondary: 2, stale: 0);
metrics.SetClusterCounts("c2", primary: 0, secondary: 1, stale: 1);
var observations = new List<(string Name, long Value, string Cluster)>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == RedundancyMetrics.MeterName)
l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
string? cluster = null;
foreach (var t in tags) if (t.Key == "cluster.id") cluster = t.Value as string;
observations.Add((instrument.Name, value, cluster ?? "?"));
});
listener.Start();
listener.RecordObservableInstruments();
observations.ShouldContain(o => o.Name == "otopcua.redundancy.primary_count" && o.Cluster == "c1" && o.Value == 1);
observations.ShouldContain(o => o.Name == "otopcua.redundancy.secondary_count" && o.Cluster == "c1" && o.Value == 2);
observations.ShouldContain(o => o.Name == "otopcua.redundancy.stale_count" && o.Cluster == "c2" && o.Value == 1);
}
}

View File

@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class RoleMapperTests
{
[Fact]
public void Maps_single_group_to_single_role()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
};
RoleMapper.Map(["ReadOnly"], mapping).ShouldBe(["ConfigViewer"]);
}
[Fact]
public void Group_match_is_case_insensitive()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
};
RoleMapper.Map(["readonly"], mapping).ShouldContain("ConfigViewer");
}
[Fact]
public void User_with_multiple_matching_groups_gets_all_distinct_roles()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
["ReadWrite"] = "ConfigEditor",
["AlarmAck"] = "FleetAdmin",
};
var roles = RoleMapper.Map(["ReadOnly", "ReadWrite", "AlarmAck"], mapping);
roles.ShouldContain("ConfigViewer");
roles.ShouldContain("ConfigEditor");
roles.ShouldContain("FleetAdmin");
roles.Count.ShouldBe(3);
}
[Fact]
public void Unknown_group_is_ignored()
{
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
};
RoleMapper.Map(["UnrelatedGroup"], mapping).ShouldBeEmpty();
}
[Fact]
public void Empty_mapping_returns_empty_roles()
{
RoleMapper.Map(["ReadOnly"], new Dictionary<string, string>()).ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// #155 — TagService CRUD round-trip coverage. Mirrors the EquipmentService test shape;
/// uses EF Core InMemory so no SQL Server is required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagServiceTests
{
[Fact]
public async Task Create_And_List_Surfaces_The_Tag()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var created = await svc.CreateAsync(draftId: 1, NewTag("Temp"), TestContext.Current.CancellationToken);
created.TagId.ShouldNotBeNullOrEmpty();
created.GenerationId.ShouldBe(1);
var list = await svc.ListAsync(1, ct: TestContext.Current.CancellationToken);
list.Count.ShouldBe(1);
list[0].Name.ShouldBe("Temp");
}
[Fact]
public async Task List_Filters_By_DriverInstance()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
await svc.CreateAsync(1, NewTag("a", driver: "drv-1"), TestContext.Current.CancellationToken);
await svc.CreateAsync(1, NewTag("b", driver: "drv-2"), TestContext.Current.CancellationToken);
await svc.CreateAsync(1, NewTag("c", driver: "drv-1"), TestContext.Current.CancellationToken);
var d1 = await svc.ListAsync(1, driverInstanceId: "drv-1", ct: TestContext.Current.CancellationToken);
d1.Count.ShouldBe(2);
d1.Select(t => t.Name).ShouldBe(new[] { "a", "c" }, ignoreOrder: true);
}
[Fact]
public async Task Update_Persists_Editable_Fields()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var t = await svc.CreateAsync(1, NewTag("Original"), TestContext.Current.CancellationToken);
t.Name = "Renamed";
t.DataType = "Float";
t.AccessLevel = TagAccessLevel.ReadWrite;
t.TagConfig = "{\"addressString\":\"40001:F\"}";
await svc.UpdateAsync(t, TestContext.Current.CancellationToken);
var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken))[0];
fresh.Name.ShouldBe("Renamed");
fresh.DataType.ShouldBe("Float");
fresh.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
fresh.TagConfig.ShouldContain("40001:F");
}
[Fact]
public async Task TagConfig_With_Advanced_Modbus_Fields_RoundTrips_Through_Factory()
{
// #156 — TagsTab serializes advanced fields (deadband / unitId / coalesceProhibited)
// into TagConfig as a structured JSON object alongside addressString. Confirm the
// shape survives a DB round-trip AND that ModbusDriverFactoryExtensions.BuildTag's
// JSON consumer accepts it. If the field names drift between the UI and the factory,
// this test catches it before users do.
using var ctx = NewContext();
var svc = new TagService(ctx);
var advancedConfig = System.Text.Json.JsonSerializer.Serialize(new
{
addressString = "40001:F:CDAB",
deadband = 0.5,
unitId = 7,
coalesceProhibited = true,
});
var t = NewTag("Tank");
t.TagConfig = advancedConfig;
await svc.CreateAsync(1, t, TestContext.Current.CancellationToken);
var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).Single();
fresh.TagConfig.ShouldContain("addressString");
fresh.TagConfig.ShouldContain("deadband");
fresh.TagConfig.ShouldContain("unitId");
fresh.TagConfig.ShouldContain("coalesceProhibited");
// Build the wrapping driver-config JSON the factory consumes (one tag, the structured
// config above as its TagConfig), then construct a driver from it. If any field name
// doesn't match the DTO, BuildTag throws here.
var driverConfig = System.Text.Json.JsonSerializer.Serialize(new
{
host = "127.0.0.1",
tags = new[]
{
new
{
name = "Tank",
addressString = "40001:F:CDAB",
deadband = 0.5,
unitId = (byte)7,
coalesceProhibited = true,
},
},
});
Should.NotThrow(() => ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverFactoryExtensions.CreateInstance(
"advanced-rt", driverConfig));
}
[Fact]
public async Task Delete_Removes_The_Row()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var t = await svc.CreateAsync(1, NewTag("Doomed"), TestContext.Current.CancellationToken);
await svc.DeleteAsync(t.TagRowId, TestContext.Current.CancellationToken);
(await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
private static Tag NewTag(string name, string driver = "drv-1") => new()
{
TagId = string.Empty, // CreateAsync auto-assigns
DriverInstanceId = driver,
Name = name,
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}

View File

@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class UnsImpactAnalyzerTests
{
private static UnsTreeSnapshot TwoAreaSnapshot() => new()
{
DraftGenerationId = 1,
RevisionToken = new DraftRevisionToken("rev-1"),
Areas =
[
new UnsAreaSummary("area-pack", "Packaging", ["line-oven", "line-wrap"]),
new UnsAreaSummary("area-asm", "Assembly", ["line-weld"]),
],
Lines =
[
new UnsLineSummary("line-oven", "Oven-2", EquipmentCount: 14, TagCount: 237),
new UnsLineSummary("line-wrap", "Wrapper", EquipmentCount: 3, TagCount: 40),
new UnsLineSummary("line-weld", "Welder", EquipmentCount: 5, TagCount: 80),
],
};
[Fact]
public void LineMove_Counts_Affected_Equipment_And_Tags()
{
var snapshot = TwoAreaSnapshot();
var move = new UnsMoveOperation(
Kind: UnsMoveKind.LineMove,
SourceClusterId: "c1", TargetClusterId: "c1",
SourceLineId: "line-oven",
TargetAreaId: "area-asm");
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
preview.AffectedEquipmentCount.ShouldBe(14);
preview.AffectedTagCount.ShouldBe(237);
preview.RevisionToken.Value.ShouldBe("rev-1");
preview.HumanReadableSummary.ShouldContain("'Oven-2'");
preview.HumanReadableSummary.ShouldContain("'Assembly'");
}
[Fact]
public void CrossCluster_LineMove_Throws()
{
var snapshot = TwoAreaSnapshot();
var move = new UnsMoveOperation(
Kind: UnsMoveKind.LineMove,
SourceClusterId: "c1", TargetClusterId: "c2",
SourceLineId: "line-oven",
TargetAreaId: "area-asm");
Should.Throw<CrossClusterMoveRejectedException>(
() => UnsImpactAnalyzer.Analyze(snapshot, move));
}
[Fact]
public void LineMove_With_UnknownSource_Throws_Validation()
{
var snapshot = TwoAreaSnapshot();
var move = new UnsMoveOperation(
UnsMoveKind.LineMove, "c1", "c1",
SourceLineId: "line-does-not-exist",
TargetAreaId: "area-asm");
Should.Throw<UnsMoveValidationException>(
() => UnsImpactAnalyzer.Analyze(snapshot, move));
}
[Fact]
public void LineMove_With_UnknownTarget_Throws_Validation()
{
var snapshot = TwoAreaSnapshot();
var move = new UnsMoveOperation(
UnsMoveKind.LineMove, "c1", "c1",
SourceLineId: "line-oven",
TargetAreaId: "area-nowhere");
Should.Throw<UnsMoveValidationException>(
() => UnsImpactAnalyzer.Analyze(snapshot, move));
}
[Fact]
public void LineMove_To_Area_WithSameName_Warns_AboutAmbiguity()
{
var snapshot = new UnsTreeSnapshot
{
DraftGenerationId = 1,
RevisionToken = new DraftRevisionToken("rev-1"),
Areas =
[
new UnsAreaSummary("area-a", "Packaging", ["line-1"]),
new UnsAreaSummary("area-b", "Assembly", ["line-2"]),
],
Lines =
[
new UnsLineSummary("line-1", "Oven", 10, 100),
new UnsLineSummary("line-2", "Oven", 5, 50),
],
};
var move = new UnsMoveOperation(
UnsMoveKind.LineMove, "c1", "c1",
SourceLineId: "line-1",
TargetAreaId: "area-b");
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
preview.CascadeWarnings.ShouldContain(w => w.Contains("already has a line named 'Oven'"));
}
[Fact]
public void AreaRename_Cascades_AcrossAllLines()
{
var snapshot = TwoAreaSnapshot();
var move = new UnsMoveOperation(
Kind: UnsMoveKind.AreaRename,
SourceClusterId: "c1", TargetClusterId: "c1",
SourceAreaId: "area-pack",
NewName: "Packaging-West");
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
preview.AffectedEquipmentCount.ShouldBe(14 + 3, "sum of lines in 'Packaging'");
preview.AffectedTagCount.ShouldBe(237 + 40);
preview.HumanReadableSummary.ShouldContain("'Packaging-West'");
}
[Fact]
public void LineMerge_CrossArea_Warns()
{
var snapshot = TwoAreaSnapshot();
var move = new UnsMoveOperation(
Kind: UnsMoveKind.LineMerge,
SourceClusterId: "c1", TargetClusterId: "c1",
SourceLineId: "line-oven",
TargetLineId: "line-weld");
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
preview.AffectedEquipmentCount.ShouldBe(14);
preview.CascadeWarnings.ShouldContain(w => w.Contains("different areas"));
}
[Fact]
public void LineMerge_SameArea_NoWarning()
{
var snapshot = TwoAreaSnapshot();
var move = new UnsMoveOperation(
Kind: UnsMoveKind.LineMerge,
SourceClusterId: "c1", TargetClusterId: "c1",
SourceLineId: "line-oven",
TargetLineId: "line-wrap");
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
preview.CascadeWarnings.ShouldBeEmpty();
}
[Fact]
public void DraftRevisionToken_Matches_OnEqualValues()
{
var a = new DraftRevisionToken("rev-1");
var b = new DraftRevisionToken("rev-1");
var c = new DraftRevisionToken("rev-2");
a.Matches(b).ShouldBeTrue();
a.Matches(c).ShouldBeFalse();
a.Matches(null).ShouldBeFalse();
}
}

View File

@@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class UnsServiceMoveTests
{
[Fact]
public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts()
{
using var ctx = NewContext();
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") },
equipmentLines: new[] { "line-a", "line-a", "line-b" });
var svc = new UnsService(ctx);
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
snap.Areas.Count.ShouldBe(2);
snap.Lines.Count.ShouldBe(3);
snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2);
snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1);
snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0);
}
[Fact]
public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads()
{
using var ctx = NewContext();
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
var svc = new UnsService(ctx);
var first = await svc.LoadSnapshotAsync(1, CancellationToken.None);
var second = await svc.LoadSnapshotAsync(1, CancellationToken.None);
second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue();
}
[Fact]
public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded()
{
using var ctx = NewContext();
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
var svc = new UnsService(ctx);
var before = await svc.LoadSnapshotAsync(1, CancellationToken.None);
await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None);
var after = await svc.LoadSnapshotAsync(1, CancellationToken.None);
after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse();
}
[Fact]
public async Task MoveLineAsync_WithMatchingToken_Reparents_Line()
{
using var ctx = NewContext();
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
lines: new[] { ("line-a", "area-1") });
var svc = new UnsService(ctx);
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None);
var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
moved.UnsAreaId.ShouldBe("area-2");
}
[Fact]
public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict()
{
using var ctx = NewContext();
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
lines: new[] { ("line-a", "area-1") });
var svc = new UnsService(ctx);
// Simulate a peer operator's concurrent edit between our preview + commit.
var stale = new DraftRevisionToken("0000000000000000");
await Should.ThrowAsync<DraftRevisionConflictException>(() =>
svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None));
var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
row.UnsAreaId.ShouldBe("area-1");
}
private static void Seed(OtOpcUaConfigDbContext ctx, long draftId,
IEnumerable<string> areas,
IEnumerable<(string line, string area)> lines,
IEnumerable<string>? equipmentLines = null)
{
foreach (var a in areas)
{
ctx.UnsAreas.Add(new UnsArea
{
GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a,
});
}
foreach (var (line, area) in lines)
{
ctx.UnsLines.Add(new UnsLine
{
GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line,
});
}
foreach (var lineId in equipmentLines ?? [])
{
ctx.Equipment.Add(new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = draftId,
EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15],
EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
UnsLineId = lineId, Name = "x", MachineCode = "m",
});
}
ctx.SaveChanges();
}
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}

View File

@@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class ValidatedNodeAclAuthoringServiceTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
public ValidatedNodeAclAuthoringServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"val-nodeacl-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
}
public void Dispose() => _db.Dispose();
[Fact]
public async Task Grant_Rejects_NonePermissions()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
draftGenerationId: 1, clusterId: "c1", ldapGroup: "cn=ops",
scopeKind: NodeAclScopeKind.Cluster, scopeId: null,
permissions: NodePermissions.None, notes: null, CancellationToken.None));
}
[Fact]
public async Task Grant_Rejects_ClusterScope_With_ScopeId()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
1, "c1", "cn=ops",
NodeAclScopeKind.Cluster, scopeId: "not-null-wrong",
NodePermissions.Read, null, CancellationToken.None));
}
[Fact]
public async Task Grant_Rejects_SubClusterScope_Without_ScopeId()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
1, "c1", "cn=ops",
NodeAclScopeKind.Equipment, scopeId: null,
NodePermissions.Read, null, CancellationToken.None));
}
[Fact]
public async Task Grant_Succeeds_When_Valid()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
var row = await svc.GrantAsync(
1, "c1", "cn=ops",
NodeAclScopeKind.Cluster, null,
NodePermissions.Read | NodePermissions.Browse, "fleet reader", CancellationToken.None);
row.LdapGroup.ShouldBe("cn=ops");
row.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.Browse);
row.NodeAclId.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task Grant_Rejects_DuplicateScopeGroup_Pair()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.WriteOperate, null, CancellationToken.None));
}
[Fact]
public async Task Grant_SameGroup_DifferentScope_IsAllowed()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
var tagRow = await svc.GrantAsync(1, "c1", "cn=ops",
NodeAclScopeKind.Tag, scopeId: "tag-xyz",
NodePermissions.WriteOperate, null, CancellationToken.None);
tagRow.ScopeKind.ShouldBe(NodeAclScopeKind.Tag);
}
[Fact]
public async Task Grant_SameGroupScope_DifferentDraft_IsAllowed()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
var draft2Row = await svc.GrantAsync(2, "c1", "cn=ops",
NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
draft2Row.GenerationId.ShouldBe(2);
}
[Fact]
public async Task UpdatePermissions_Rejects_None()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
var row = await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
await Should.ThrowAsync<InvalidNodeAclGrantException>(
() => svc.UpdatePermissionsAsync(row.NodeAclRowId, NodePermissions.None, null, CancellationToken.None));
}
[Fact]
public async Task UpdatePermissions_RoundTrips_NewFlags()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
var row = await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
var updated = await svc.UpdatePermissionsAsync(row.NodeAclRowId,
NodePermissions.Read | NodePermissions.WriteOperate, "bumped", CancellationToken.None);
updated.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
updated.Notes.ShouldBe("bumped");
}
[Fact]
public async Task UpdatePermissions_MissingRow_Throws()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(
() => svc.UpdatePermissionsAsync(Guid.NewGuid(), NodePermissions.Read, null, CancellationToken.None));
}
}

View File

@@ -0,0 +1,34 @@
<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\Server\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>