54f58c583c
- 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.
152 lines
8.2 KiB
C#
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
|
|
}
|
|
}
|