feat(adminui): reject {{equip}} virtual tags whose equipment has no derivable base
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
|
||||||
|
/// <summary>Extracts the driver-side <c>FullName</c> from a <c>Tag.TagConfig</c> JSON blob.
|
||||||
|
/// Mirrors <c>ScriptTagCatalog.ExtractFullNameFromTagConfig</c> (kept as a small local copy so
|
||||||
|
/// VirtualTag validation does not depend on the ScriptAnalysis catalog). Falls back to the raw
|
||||||
|
/// blob when it is not a JSON object with a string <c>FullName</c>.</summary>
|
||||||
|
internal static class TagConfigFullName
|
||||||
|
{
|
||||||
|
/// <summary>Extract the FullName, or the raw blob when absent.</summary>
|
||||||
|
/// <param name="tagConfig">The Tag.TagConfig JSON.</param>
|
||||||
|
/// <returns>The FullName string, or the raw blob.</returns>
|
||||||
|
public static string Extract(string? tagConfig)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig ?? string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||||
|
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||||
|
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||||
|
&& fullName.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return fullName.GetString() ?? tagConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException) { /* fall through */ }
|
||||||
|
return tagConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
@@ -889,6 +890,34 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
|
||||||
|
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
|
||||||
|
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
|
||||||
|
/// base exists). The script source is fetched from the supplied (in-scope) context.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
|
||||||
|
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
|
||||||
|
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
|
||||||
|
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
|
||||||
|
|
||||||
|
var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId)
|
||||||
|
.Select(t => t.TagConfig).ToListAsync(ct);
|
||||||
|
var fullNames = configs.Select(TagConfigFullName.Extract);
|
||||||
|
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
|
||||||
|
{
|
||||||
|
// NOTE: literal {{equip}} must survive — in a C# interpolated string `{{`/`}}`
|
||||||
|
// collapse to single braces, so keep the token text in a NON-interpolated segment.
|
||||||
|
return new UnsMutationResult(false,
|
||||||
|
$"Equipment '{equipmentId}' has no single tag base, so the "
|
||||||
|
+ "{{equip}} token can't be resolved. Add at least one driver tag under this "
|
||||||
|
+ "equipment (all sharing one object prefix), or remove {{equip}} from the script.");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<UnsMutationResult> CreateVirtualTagAsync(
|
public async Task<UnsMutationResult> CreateVirtualTagAsync(
|
||||||
string equipmentId,
|
string equipmentId,
|
||||||
@@ -918,6 +947,9 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
|||||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
|
||||||
|
if (equipGuard is not null) return equipGuard.Value;
|
||||||
|
|
||||||
db.VirtualTags.Add(new VirtualTag
|
db.VirtualTags.Add(new VirtualTag
|
||||||
{
|
{
|
||||||
VirtualTagId = input.VirtualTagId,
|
VirtualTagId = input.VirtualTagId,
|
||||||
@@ -964,6 +996,9 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
|||||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
|
||||||
|
if (equipGuard is not null) return equipGuard.Value;
|
||||||
|
|
||||||
// EquipmentId is preserved — virtual tags are always equipment-bound (plan decision #2).
|
// EquipmentId is preserved — virtual tags are always equipment-bound (plan decision #2).
|
||||||
entity.Name = input.Name;
|
entity.Name = input.Name;
|
||||||
entity.DataType = input.DataType;
|
entity.DataType = input.DataType;
|
||||||
|
|||||||
+189
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user