feat(adminui): reject {{equip}} virtual tags whose equipment has no derivable base

This commit is contained in:
Joseph Doherty
2026-06-10 07:58:38 -04:00
parent 66ea9c56f6
commit cadd6c60b7
3 changed files with 252 additions and 0 deletions
@@ -0,0 +1,189 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the save-time equipment-relative-path guard on <see cref="UnsTreeService"/>: when a
/// VirtualTag binds a script that uses the reserved <c>{{equip}}</c> token, the owning equipment
/// must have a derivable tag base (≥1 driver tag, all sharing one object prefix). Otherwise the
/// create/update is rejected with an error naming the equipment and the unresolvable token.
/// </summary>
/// <remarks>
/// Reuses the shared <see cref="UnsTreeTestDb"/> InMemory fixture pattern: a uniquely-named
/// InMemory database seeded with an area→line→equipment path plus a script, with driver tags
/// added per-test so the base-derivation outcome is what each case isolates.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class VirtualTagEquipTokenValidationTests
{
private const string EquipBaseScript = "return ctx.GetTag(\"{{equip}}.X\");";
private const string PlainScript = "return ctx.GetTag(\"TestMachine_001.X\");";
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-equiptoken-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds an area→line→equipment path (equipment id <c>EQ-1</c>) plus one script whose source is
/// <paramref name="scriptSource"/>, and optionally a driver tag whose <c>TagConfig</c> carries the
/// supplied <c>FullName</c> so the equipment has a derivable base.
/// </summary>
private static void Seed(string dbName, string scriptSource, string? tagFullName)
{
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.Equipment.Add(new Equipment
{
EquipmentId = "EQ-1",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = "TestMachine_001",
MachineCode = "machine_001",
});
db.Scripts.Add(new Script
{
ScriptId = "SCRIPT-1",
Name = "compute",
SourceCode = scriptSource,
SourceHash = "hash-1",
Language = "CSharp",
});
if (tagFullName is not null)
{
db.Tags.Add(new Tag
{
TagId = "TAG-1",
DriverInstanceId = "DRV-1",
EquipmentId = "EQ-1",
Name = "x",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = $"{{\"FullName\":\"{tagFullName}\"}}",
});
}
db.SaveChanges();
}
private static VirtualTagInput Input(string virtualTagId = "VTAG-1", string name = "computed") =>
new(virtualTagId, name, DataType: "Double", ScriptId: "SCRIPT-1",
ChangeTriggered: true, TimerIntervalMs: null, Historize: false, Enabled: true);
/// <summary>Seeds an existing VirtualTag and returns its RowVersion (for update-path tests).</summary>
private static byte[] SeedVirtualTagAndRowVersion(string dbName)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = "VTAG-1",
EquipmentId = "EQ-1",
Name = "computed",
DataType = "Double",
ScriptId = "SCRIPT-1",
});
db.SaveChanges();
return db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion;
}
// ----- Create -----
/// <summary>{{equip}} script + a driver tag whose FullName gives a base → create succeeds.</summary>
[Fact]
public async Task Create_equip_token_with_derivable_base_succeeds()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
/// <summary>{{equip}} script + no driver tags → create rejected, error names equipment + token.</summary>
[Fact]
public async Task Create_equip_token_without_base_rejected()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: null);
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("EQ-1");
result.Error.ShouldContain("{{equip}}");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
/// <summary>A script with no {{equip}} token → create succeeds regardless of tags.</summary>
[Fact]
public async Task Create_no_equip_token_succeeds_without_tags()
{
var (service, dbName) = Fresh();
Seed(dbName, PlainScript, tagFullName: null);
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
// ----- Update -----
/// <summary>{{equip}} script + a derivable base → update succeeds.</summary>
[Fact]
public async Task Update_equip_token_with_derivable_base_succeeds()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
var rv = SeedVirtualTagAndRowVersion(dbName);
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
/// <summary>{{equip}} script + no driver tags → update rejected, error names equipment + token.</summary>
[Fact]
public async Task Update_equip_token_without_base_rejected()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: null);
var rv = SeedVirtualTagAndRowVersion(dbName);
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("EQ-1");
result.Error.ShouldContain("{{equip}}");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").Name.ShouldBe("computed");
}
/// <summary>A script with no {{equip}} token → update succeeds regardless of tags.</summary>
[Fact]
public async Task Update_no_equip_token_succeeds_without_tags()
{
var (service, dbName) = Fresh();
Seed(dbName, PlainScript, tagFullName: null);
var rv = SeedVirtualTagAndRowVersion(dbName);
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
}