Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsSegmentGuardTests.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

258 lines
11 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Gitea #535 — every UNS authoring path applies the segment rule the deploy gate applies.
/// </summary>
/// <remarks>
/// <para>
/// The bug was not that a bad name crashed anything: it saved perfectly, and the <b>next
/// deployment</b> failed with <c>UnsSegmentInvalid</c>. Because a deployment snapshots the whole
/// database, one badly-named area blocked every cluster's deploy, discovered by someone other
/// than whoever typed it.
/// </para>
/// <para>
/// So these are rejection tests, and each is paired with a name that must still be <b>accepted</b>
/// — a guard that refused everything would satisfy the rejection half alone. They live at the
/// service, not the form, because a form attribute cannot cover the CSV import and this repo has
/// no bUnit to reach a form with.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsSegmentGuardTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-segment-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
private static void SeedAreaAndLine(string dbName)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode) =>
new(name, machineCode, "LINE-1",
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
/// <summary>The two names that actually caused the outage, plus the neighbouring shapes.</summary>
public static TheoryData<string> BadNames() => new()
{
"Packaging", // the reported case: a capital
"Line 1", // the reported case: a space
"line_1", // underscore is not in the segment alphabet
"line/1", // a slash would split into two NodeId levels
"", // empty
" ", // whitespace
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 33 chars
};
/// <summary>Names that must keep working — the control for every rejection below.</summary>
public static TheoryData<string> GoodNames() => new()
{
"packaging",
"line-1",
"a",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // exactly 32
UnsSegment.DefaultSegment, // the gate accepts it, so the UI must too
};
// --- Area ---------------------------------------------------------------------------------
[Theory]
[MemberData(nameof(BadNames))]
public async Task CreateArea_rejects_a_name_the_deploy_gate_would_reject(string name)
{
var (service, _) = Fresh();
var result = await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null, TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
[Theory]
[MemberData(nameof(GoodNames))]
public async Task CreateArea_accepts_a_valid_segment(string name)
{
var (service, _) = Fresh();
(await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
}
/// <summary>
/// Renaming is the other way in, and the one an operator reaches after the name is already
/// deployed — so it is guarded too, and the guard runs before the row is touched.
/// </summary>
[Fact]
public async Task UpdateArea_rejects_a_rename_to_an_invalid_segment_and_leaves_the_row_alone()
{
var (service, dbName) = Fresh();
await service.CreateAreaAsync("MAIN", "AREA-1", "packaging", null, TestContext.Current.CancellationToken);
var rowVersion = Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").RowVersion;
(await service.UpdateAreaAsync("AREA-1", "Packaging", null, "MAIN", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeFalse();
Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("packaging");
(await service.UpdateAreaAsync("AREA-1", "assembly", null, "MAIN", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("assembly");
}
// --- Line ---------------------------------------------------------------------------------
[Theory]
[MemberData(nameof(BadNames))]
public async Task CreateLine_rejects_a_name_the_deploy_gate_would_reject(string name)
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var result = await service.CreateLineAsync("AREA-1", "LINE-NEW", name, null, TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
[Fact]
public async Task CreateLine_accepts_a_valid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
(await service.CreateLineAsync("AREA-1", "LINE-NEW", "line-2", null, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
}
[Fact]
public async Task UpdateLine_rejects_a_rename_to_an_invalid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var rowVersion = Load(dbName).UnsLines.Single(l => l.UnsLineId == "LINE-1").RowVersion;
(await service.UpdateLineAsync("LINE-1", "Line 1", null, "AREA-1", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeFalse();
(await service.UpdateLineAsync("LINE-1", "line-1", null, "AREA-1", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
}
// --- Equipment ----------------------------------------------------------------------------
[Theory]
[MemberData(nameof(BadNames))]
public async Task CreateEquipment_rejects_a_name_the_deploy_gate_would_reject(string name)
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
(await service.CreateEquipmentAsync(Input(name, "mc_1"), TestContext.Current.CancellationToken)).Ok.ShouldBeFalse();
}
[Fact]
public async Task CreateEquipment_accepts_a_valid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
(await service.CreateEquipmentAsync(Input("packer-1", "mc_1"), TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
}
[Fact]
public async Task UpdateEquipment_rejects_a_rename_to_an_invalid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var created = await service.CreateEquipmentAsync(Input("packer-1", "mc_1"), TestContext.Current.CancellationToken);
created.Ok.ShouldBeTrue();
var row = Load(dbName).Equipment.Single();
(await service.UpdateEquipmentAsync(row.EquipmentId, Input("Packer 1", "mc_1"), row.RowVersion, TestContext.Current.CancellationToken))
.Ok.ShouldBeFalse();
(await service.UpdateEquipmentAsync(row.EquipmentId, Input("packer-2", "mc_1"), row.RowVersion, TestContext.Current.CancellationToken))
.Ok.ShouldBeTrue();
}
// --- CSV import ---------------------------------------------------------------------------
/// <summary>
/// The bulk path is the one a form attribute could never have covered, and the one where a bad
/// row does the most damage — an import of hundreds lands, and the fleet stops deploying.
/// </summary>
/// <remarks>
/// A bad name is an <b>error</b>, not a skip: a skip means "you already have this", which would
/// tell the operator the row is present when it is not.
/// </remarks>
[Fact]
public async Task Import_reports_an_invalid_name_as_an_error_and_still_inserts_the_good_rows()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var result = await service.ImportEquipmentAsync(
[
Input("packer-1", "mc_1"),
Input("Packer 2", "mc_2"),
Input("packer-3", "mc_3"),
], TestContext.Current.CancellationToken);
result.Inserted.ShouldBe(2);
result.Skipped.ShouldBe(0, "a bad name is not a row the operator already has");
result.Errors.ShouldHaveSingleItem().ShouldContain("mc_2");
Load(dbName).Equipment.Select(e => e.Name).OrderBy(n => n)
.ShouldBe(["packer-1", "packer-3"]);
}
private static Configuration.OtOpcUaConfigDbContext Load(string dbName) =>
UnsTreeTestDb.CreateNamed(dbName);
}
/// <summary>The shared rule itself — one authority, so the gate and the AdminUI cannot drift (#535).</summary>
[Trait("Category", "Unit")]
public sealed class UnsSegmentTests
{
/// <summary>
/// The attribute pattern the forms use must accept exactly what <see cref="UnsSegment.IsValid"/>
/// accepts. They are two expressions of one rule, and a form stricter than the service is a field
/// an operator cannot satisfy; a form looser than it is the #535 bug again.
/// </summary>
[Theory]
[InlineData("packaging")]
[InlineData("line-1")]
[InlineData("_default")]
[InlineData("Packaging")]
[InlineData("Line 1")]
[InlineData("line_1")]
[InlineData("")]
[InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
public void The_attribute_pattern_agrees_with_IsValid(string candidate)
{
var byAttribute = System.Text.RegularExpressions.Regex.IsMatch(
candidate, UnsSegment.AttributePattern);
byAttribute.ShouldBe(UnsSegment.IsValid(candidate));
}
/// <summary>
/// <see cref="UnsSegment.Pattern"/> alone does NOT admit <c>_default</c>. Pinned because using it
/// on a form — the obvious mistake, and what the equipment form did — makes the UI stricter than
/// the deploy gate, so a <c>_default</c> row becomes uneditable through the AdminUI.
/// </summary>
[Fact]
public void The_bare_pattern_excludes_the_default_segment_but_IsValid_admits_it()
{
System.Text.RegularExpressions.Regex
.IsMatch(UnsSegment.DefaultSegment, UnsSegment.Pattern).ShouldBeFalse();
UnsSegment.IsValid(UnsSegment.DefaultSegment).ShouldBeTrue();
}
}