Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.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

100 lines
4.6 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>lmxopcua#803: the Heidenhain read-only, credential and endpoint deploy gates.</summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorHeidenhainTests
{
private static DriverInstance Driver(string config = "{}", string type = "Heidenhain") => new()
{
DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = type, DriverConfig = config,
};
private static Device Dev(string config = """{"Host":"10.0.0.5"}""") => new()
{
DeviceId = "d1", DriverInstanceId = "hh", Name = "dev1", DeviceConfig = config,
};
private static Tag T(TagAccessLevel access) => new()
{
TagId = "t1", DeviceId = "d1", Name = "ExecState", DataType = "Int32", AccessLevel = access,
TagConfig = """{"address":"Status/ExecutionState"}""",
};
private static IReadOnlyList<ValidationError> Validate(DriverInstance d, Device dev, params Tag[] tags) =>
DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1, ClusterId = "c", DriverInstances = [d], Devices = [dev], Tags = tags,
});
[Fact]
public void A_read_only_tag_on_a_configured_control_is_valid()
=> Validate(Driver(), Dev(), T(TagAccessLevel.Read)).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
[Fact]
public void A_ReadWrite_tag_is_refused_so_no_node_is_ever_writable()
{
var e = Validate(Driver(), Dev(), T(TagAccessLevel.ReadWrite)).Single(x => x.Code == HeidenhainDeployChecks.WritableCode);
e.Context.ShouldBe("t1");
e.Message.ShouldContain("read-only");
}
[Theory]
[InlineData("""{"Access":{"InspectPassword":"hunter2"}}""", "{\"Host\":\"10.0.0.5\"}")]
[InlineData("{}", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""")]
public void A_literal_password_is_refused_without_echoing_it(string driverConfig, string deviceConfig)
{
var errors = Validate(Driver(driverConfig), Dev(deviceConfig), T(TagAccessLevel.Read));
errors.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode);
errors.ShouldAllBe(e => !e.Message.Contains("hunter2"));
}
[Fact]
public void Secret_and_env_references_are_accepted()
=> Validate(Driver("""{"Access":{"InspectPassword":"secret:heidenhain/inspect","DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC"}}"""), Dev(), T(TagAccessLevel.Read))
.ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
[Fact]
public void A_device_with_tags_but_no_endpoint_is_refused_while_an_empty_device_is_not()
{
Validate(Driver(), Dev("{}"), T(TagAccessLevel.Read)).ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode && e.Context == "d1");
Validate(Driver(), Dev("{}")).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
}
[Fact]
public void A_tag_data_type_that_disagrees_with_the_published_type_is_refused()
{
var tag = T(TagAccessLevel.Read);
tag.DataType = "Float";
var e = Validate(Driver(), Dev(), tag).Single(x => x.Code == HeidenhainDeployChecks.DataTypeCode);
e.Context.ShouldBe("t1");
e.Message.ShouldContain("Int32");
var binding = T(TagAccessLevel.Read);
binding.TagConfig = """{"address":"PLC:DWORD:0","dataType":"Int64"}""";
binding.DataType = "Int64";
Validate(Driver(), Dev(), binding).ShouldNotContain(x => x.Code == HeidenhainDeployChecks.DataTypeCode);
}
[Theory]
[InlineData("env:ConnectionStrings__ConfigDb")]
[InlineData("secret:galaxy/inst1/apikey")]
public void An_out_of_namespace_reference_is_refused(string reference)
=> Validate(Driver("{\"Access\":{\"InspectPassword\":\"" + reference + "\"}}"), Dev(), T(TagAccessLevel.Read))
.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode);
[Fact]
public void Out_of_range_settings_are_refused()
=> Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read))
.ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode);
[Fact]
public void Other_drivers_are_unaffected()
=> Validate(Driver("""{"Access":{"InspectPassword":"x"}}""", type: "Modbus"), Dev(), T(TagAccessLevel.ReadWrite))
.ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
}