Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainSavePathTests.cs
T
Joseph Doherty 54f58c583c fix(heidenhain): refuse literal credentials and writable/mistyped tags at the save path (#803 review)
- Review 1: RawTreeService Create/UpdateDriver and Create/UpdateDevice refuse a Heidenhain
  config whose password fields are not blank / secret:heidenhain/... / env:OTOPCUA_HEIDENHAIN_...
  (DriverConfigSaveGate over HeidenhainOptionsParser.DescribeCredentialProblems: top level,
  Access, Devices[], case-insensitive keys; the value is never echoed), so a literal password
  never reaches the draft DB. The driver and device modals show the reason and disable Save.
- Review 8: CreateTag/UpdateTag/ImportTags (tag modal, manual entry, CSV import, browse commit)
  refuse a ReadWrite Heidenhain tag or a Tag.DataType that disagrees with what the driver
  publishes (HeidenhainTagRules: catalog type, Float64 => Double, or the binding's dataType).
  The tag modal and manual-entry grid fix the access level at Read and derive the data type from
  the address (RawTagRowDefaults). New deploy gate HeidenhainTagDataTypeMismatch.
- docs/drivers/Heidenhain.md: credential namespace convention + why, the save-path and deploy
  enforcement, optional-role credential behaviour, BadConfigurationError, backoff/ordering notes.

Tests: HeidenhainSavePathTests (service save path; red with the gates disabled) and new
DraftValidatorHeidenhainTests cases.
2026-11-10 23:54:32 -05:00

152 lines
8.2 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// #803 review findings 1 + 8 at the SAVE path (RawTreeService), not just the editor models: a literal or
/// out-of-namespace Heidenhain password never reaches the config DB, and a Heidenhain tag row is refused when it
/// is ReadWrite or carries a data type the driver does not publish — through every authoring entry point
/// (modal create/update, manual entry = CreateTagAsync, CSV/browse = ImportTagsAsync).
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainSavePathTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task<(RawTreeService Svc, string Db, string DriverId, string DeviceId)> HeidenhainAsync()
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, "{}", Ct);
created.Ok.ShouldBeTrue(created.Error);
using var db = RawTreeTestDb.CreateNamed(name);
var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId);
return (svc, name, created.CreatedId!, device.DeviceId);
}
// ------------------------------------------------------------------ finding 1: credentials never persisted
[Theory]
[InlineData("""{"Access":{"InspectPassword":"hunter2"}}""")]
[InlineData("""{"access":{"dncpassword":"hunter2"}}""")]
[InlineData("""{"Access":{"DataPassword":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData("""{"Devices":[{"Host":"h","InspectPassword":"secret:galaxy/inst1/apikey"}]}""")]
public async Task CreateDriver_refuses_a_literal_or_out_of_namespace_password_and_persists_nothing(string config)
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, config, Ct);
result.Ok.ShouldBeFalse();
result.Error!.ShouldNotContain("hunter2");
using var db = RawTreeTestDb.CreateNamed(name);
db.DriverInstances.ShouldNotContain(d => d.Name == "hh1");
}
[Fact]
public async Task UpdateDriver_refuses_a_literal_password_and_keeps_the_stored_config()
{
var (svc, dbName, driverId, _) = await HeidenhainAsync();
byte[] rv;
using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
var result = await svc.UpdateDriverAsync(driverId, "hh1", """{"Access":{"InspectPassword":"hunter2"}}""", null, rv, Ct);
result.Ok.ShouldBeFalse();
using var check = RawTreeTestDb.CreateNamed(dbName);
check.DriverInstances.Single(d => d.DriverInstanceId == driverId).DriverConfig.ShouldNotContain("hunter2");
}
[Fact]
public async Task CreateDevice_and_UpdateDevice_refuse_a_literal_password()
{
var (svc, dbName, driverId, deviceId) = await HeidenhainAsync();
(await svc.CreateDeviceAsync(driverId, "cnc2", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""", Ct)).Ok.ShouldBeFalse();
byte[] rv;
using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.Devices.Single(d => d.DeviceId == deviceId).RowVersion;
(await svc.UpdateDeviceAsync(deviceId, "Device1x", """{"Host":"10.0.0.5","DataPassword":"hunter2"}""", true, rv, Ct)).Ok.ShouldBeFalse();
using var check = RawTreeTestDb.CreateNamed(dbName);
check.Devices.Where(d => d.DriverInstanceId == driverId).ShouldAllBe(d => !d.DeviceConfig.Contains("hunter2"));
}
[Fact]
public async Task Namespaced_references_save_and_other_drivers_are_unaffected()
{
var (svc, _, driverId, _) = await HeidenhainAsync();
(await svc.CreateDeviceAsync(driverId, "cnc2",
"""{"Host":"10.0.0.5","InspectPassword":"secret:heidenhain/cnc2/inspect","DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC"}""", Ct)).Ok.ShouldBeTrue();
// A Modbus driver's config is none of this gate's business.
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "mb9", DriverTypeNames.Modbus, """{"InspectPassword":"x"}""", Ct)).Ok.ShouldBeTrue();
}
[Fact]
public void The_modal_save_gate_is_the_same_rule()
{
DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"hunter2"}}""").ShouldNotBeNull();
DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:heidenhain/a"}}""").ShouldBeNull();
DriverConfigSaveGate.Validate(DriverTypeNames.Modbus, """{"Access":{"InspectPassword":"hunter2"}}""").ShouldBeNull();
}
// ------------------------------------------------------------------ finding 8: tag rows
private static RawTagInput Row(string name, string dataType, TagAccessLevel access, string config) =>
new(name, dataType, access, WriteIdempotent: false, PollGroupId: null, config);
private const string Exec = """{"address":"Status/ExecutionState"}""";
[Fact]
public async Task CreateTag_refuses_ReadWrite_and_a_mismatched_data_type()
{
var (svc, _, _, deviceId) = await HeidenhainAsync();
(await svc.CreateTagAsync(deviceId, null, Row("a", "Int32", TagAccessLevel.ReadWrite, Exec), Ct)).Ok.ShouldBeFalse();
(await svc.CreateTagAsync(deviceId, null, Row("b", "Float", TagAccessLevel.Read, Exec), Ct)).Ok.ShouldBeFalse();
(await svc.CreateTagAsync(deviceId, null, Row("c", "Int32", TagAccessLevel.Read, Exec), Ct)).Ok.ShouldBeTrue();
(await svc.CreateTagAsync(deviceId, null,
Row("d", "Double", TagAccessLevel.Read, """{"address":"Axes/Positions","isArray":true}"""), Ct)).Ok.ShouldBeTrue();
(await svc.CreateTagAsync(deviceId, null,
Row("e", "Int64", TagAccessLevel.Read, """{"address":"PLC:DWORD:0","dataType":"Int64"}"""), Ct)).Ok.ShouldBeTrue();
(await svc.CreateTagAsync(deviceId, null,
Row("f", "Int32", TagAccessLevel.Read, """{"address":"PLC:DWORD:0","dataType":"Int64"}"""), Ct)).Ok.ShouldBeFalse();
}
[Fact]
public async Task UpdateTag_refuses_ReadWrite()
{
var (svc, dbName, _, deviceId) = await HeidenhainAsync();
var created = await svc.CreateTagAsync(deviceId, null, Row("a", "Int32", TagAccessLevel.Read, Exec), Ct);
byte[] rv;
using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.Tags.Single(t => t.TagId == created.CreatedId).RowVersion;
(await svc.UpdateTagAsync(created.CreatedId!, Row("a", "Int32", TagAccessLevel.ReadWrite, Exec), rv, Ct)).Ok.ShouldBeFalse();
using var check = RawTreeTestDb.CreateNamed(dbName);
check.Tags.Single(t => t.TagId == created.CreatedId).AccessLevel.ShouldBe(TagAccessLevel.Read);
}
[Fact]
public async Task ImportTags_names_the_ReadWrite_and_mismatched_rows_and_inserts_nothing()
{
var (svc, dbName, _, deviceId) = await HeidenhainAsync();
var outcome = await svc.ImportTagsAsync(deviceId,
[
new RawTagImportRow(null, Row("ok", "Int32", TagAccessLevel.Read, Exec)),
new RawTagImportRow(null, Row("rw", "Int32", TagAccessLevel.ReadWrite, Exec)),
new RawTagImportRow(null, Row("dt", "String", TagAccessLevel.Read, Exec)),
], Ct);
outcome.Errors.Count.ShouldBe(2);
using var db = RawTreeTestDb.CreateNamed(dbName);
db.Tags.ShouldNotContain(t => t.DeviceId == deviceId);
}
[Fact]
public void Row_defaults_force_Read_and_derive_the_data_type()
{
var access = TagAccessLevel.ReadWrite;
var dataType = "Float";
RawTagRowDefaults.Apply(DriverTypeNames.Heidenhain, """{"address":"Overrides/Feed"}""", a => access = a, d => dataType = d);
access.ShouldBe(TagAccessLevel.Read);
dataType.ShouldBe("Double");
RawTagRowDefaults.Apply(DriverTypeNames.Modbus, Exec, a => access = TagAccessLevel.ReadWrite, d => dataType = "x");
dataType.ShouldBe("Double"); // untouched for other drivers
}
}