From b95b99ba8e80e059c75119609c8b1a382a7e4bd2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Jul 2026 15:34:31 -0400 Subject: [PATCH 1/4] fix(drivers): correct 16 wrong OPC UA status-code constants + guard them by reflection (#497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every value verified against Opc.Ua.StatusCodes in the pinned SDK (1.5.378.106) by reflection, not by copying siblings. All are client-visible: OPC UA clients branch on status. The three named in #497: - Historian.Gateway SampleMapper "BadNoData" 0x800E0000 -> 0x809B0000 (0x800E0000 is BadServerHalted) - Galaxy "BadTimeout" 0x800B0000 -> 0x800A0000 (0x800B0000 is BadServiceUnsupported) - TwinCAT BadTypeMismatch 0x80730000 -> 0x80740000 (0x80730000 is BadWriteNotSupported) BadTypeMismatch was wrong in three MORE drivers the issue did not name — FOCAS, AbLegacy and AbCip carried the same 0x80730000. Every call site is a FormatException/InvalidCastException conversion failure, so the name was right and the value was wrong in all four. Adding the reflection guard then surfaced nine further defects offline tests had never checked: - Galaxy GoodLocalOverride 0x00D80000 -> 0x00960000 (not a UA code at all) - Galaxy UncertainLastUsableValue 0x40A40000 -> 0x40900000 - Galaxy UncertainSensorNotAccurate 0x408D0000 -> 0x40930000 - Galaxy UncertainEngineeringUnitsExceeded 0x408E0000 -> 0x40940000 - Galaxy UncertainSubNormal 0x408F0000 -> 0x40950000 - TwinCAT BadOutOfService 0x80BE0000 -> 0x808D0000 (was BadProtocolVersionUnsupported) - TwinCAT BadInvalidState 0x80350000 -> 0x80AF0000 (was BadAttributeIdInvalid) - AbCip/AbLegacy GoodMoreData 0x00A70000 -> 0x00A60000 (was GoodCommunicationEvent) - Historian.Gateway GatewayQualityMapper Good_LocalOverride 0x00D80000 -> 0x00960000 Galaxy's whole Uncertain block read as though the OPC DA quality byte could be shifted into the UA substatus position; it cannot — the substatuses are an unrelated enumeration. GatewayQualityMapper already held the correct table, which is what the Galaxy values are now reconciled against. Guard: StatusCodeParityTests (Core.Abstractions.Tests, which already project- references every driver) reflects over every `const uint` in the deployed ZB.MOM.WW.OtOpcUa.Driver.*.dll whose name reads as a status code, and asserts it equals Opc.Ua.StatusCodes.. Discovery is by convention, so a new driver assembly referenced by that project is covered with no edit here. It went red on all nine defects above before the fixes and now checks 109 constants green. Because reflection can only see a NAMED constant, two inline literals were hoisted so the guard can reach them: Galaxy's BadTimeout/Bad into StatusCodeMap, and GatewayQualityMapper's 15-entry table into a new HistorianStatusCodes. An inline `0x800B0000u, // BadTimeout` at a call site is exactly how the Galaxy defect survived. The drivers stay SDK-free by design; only the test project references Opc.Ua.Core, as the oracle. Also corrected: tests that pinned the wrong values (Galaxy StatusCodeMapTests, SampleMapperTests, GatewayQualityMapperTests — all written from the same bad source), a mislabelled comment in DriverInstanceActorTests, and stale constants in two design docs, one of them the unexecuted MTConnect driver design that would have propagated BadNoData's wrong value into a new driver. The CLI's SnapshotFormatter value->name table was checked against the SDK and is correct; no change needed. --- Directory.Packages.props | 4 + ...6-otopcua-historian-gateway-integration.md | 6 +- .../2026-07-15-mtconnect-driver-design.md | 2 +- .../AbCipStatusMapper.cs | 4 +- .../AbLegacyStatusMapper.cs | 4 +- .../FocasStatusMapper.cs | 2 +- .../GalaxyDriver.cs | 4 +- .../Runtime/StatusCodeMap.cs | 27 ++- .../Mapping/GatewayQualityMapper.cs | 36 ++-- .../Mapping/HistorianStatusCodes.cs | 75 ++++++++ .../Mapping/SampleMapper.cs | 4 +- .../TwinCATStatusMapper.cs | 6 +- .../StatusCodeParityTests.cs | 160 ++++++++++++++++++ ....WW.OtOpcUa.Core.Abstractions.Tests.csproj | 6 + .../Runtime/StatusCodeMapTests.cs | 14 +- .../Mapping/GatewayQualityMapperTests.cs | 2 +- .../Mapping/SampleMapperTests.cs | 2 +- .../Drivers/DriverInstanceActorTests.cs | 2 +- 18 files changed, 311 insertions(+), 49 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/HistorianStatusCodes.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/StatusCodeParityTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a30e3e2d..87785628 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -77,6 +77,10 @@ + + + all @@ -35,6 +37,10 @@ + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs index c048431c..3ec18e17 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs @@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests /// The expected OPC UA status code. [Theory] [InlineData((byte)192, 0x00000000u)] // Good - [InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride + [InlineData((byte)216, 0x00960000u)] // Good_LocalOverride [InlineData((byte)64, 0x40000000u)] // Uncertain - [InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue - [InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate - [InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded - [InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal + [InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue + [InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate + [InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded + [InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal [InlineData((byte)0, 0x80000000u)] // Bad [InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError [InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected @@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests /// The expected OPC DA category byte. [Theory] [InlineData(0x00000000u, (byte)192)] // Good - [InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category + [InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category [InlineData(0x40000000u, (byte)64)] // Uncertain - [InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category + [InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category [InlineData(0x80000000u, (byte)0)] // Bad [InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category [InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs index 15c7fced..c2cba1f6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs @@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests { [Theory] [InlineData(192, 0x00000000u)] // Good - [InlineData(216, 0x00D80000u)] // Good_LocalOverride + [InlineData(216, 0x00960000u)] // Good_LocalOverride [InlineData(64, 0x40000000u)] // Uncertain [InlineData(0, 0x80000000u)] // Bad [InlineData(8, 0x808A0000u)] // Bad_NotConnected diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs index 969b9764..29092ae4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs @@ -37,7 +37,7 @@ public sealed class SampleMapperTests { var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) }; var snap = SampleMapper.ToAggregateSnapshot(a); - Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData + Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData Assert.Null(snap.Value); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs index 381756e3..59794e08 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs @@ -88,7 +88,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase [Fact] public async Task Write_propagates_status_code_on_Bad_result() { - const uint badStatus = 0x80340000; // BadOutOfService — top severity bits = 10b + const uint badStatus = 0x80340000; // BadNodeIdUnknown — top severity bits = 10b var driver = new WritableStubDriver { NextStatusCode = badStatus }; var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); From 1919a8e5382c6cfe7f4cfa2d7a6b97df0d6c7a65 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Jul 2026 15:37:48 -0400 Subject: [PATCH 2/4] feat(config): reject a persisted Sql connectionString at the deploy gate (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sql driver's "a pasted literal connection string is never persisted" guarantee was enforced only on the READ path: SqlDriverConfigDto has no connectionString property and UnmappedMemberHandling.Skip drops the key on deserialization. Nothing stopped it being WRITTEN. Config blobs are schemaless JSON columns and the fallback authoring pattern for a driver without a typed form is a raw-JSON textarea, so an operator pasting {"connectionString":"Server=...;Password=..."} would put a live credential in the ConfigDb — persisted, replicated to every node's artifact cache, readable by anyone with config access — while the runtime silently ignored it and the driver failed to connect. Discarding a secret on read is not the same as refusing to store it. DraftValidator.ValidateSqlConnectionStringNotPersisted fails the deploy (SqlConnectionStringPersisted) when a Sql-typed driver instance's DriverConfig, or the DeviceConfig of any device beneath it, carries a top-level connectionString key. - Both surfaces are checked because DeviceConfig is merged onto DriverConfig before the DTO sees it — a credential pasted into the device blob is the identical leak. - The key is matched case-insensitively: System.Text.Json binds ConnectionString to a connectionString property by default, so an ordinal match would leave the obvious bypass open. - Only the top level is scanned; the DTO is flat, so a nested occurrence cannot bind and is not the mistake this rule exists to catch. - The message never echoes the value — validation errors reach the AdminUI, the deploy log and the audit trail, and repeating the string there would leak the credential the rule is refusing to store. Pinned by a test. - Malformed/blank/non-object JSON never throws: a parse failure would take down every other rule in the same pass. 16 tests. Verified falsifiable — with the rule disabled the 6 positive assertions fail and the 10 negatives stay green, so none of them passes vacuously. Design §8.2's "if an inline connectionString is ever permitted (dev convenience only), the AdminUI redacts it" carve-out is withdrawn in the same change: redaction only hides a credential that has already been written. Not covered, and filed as a follow-up: ClusterNode.DriverConfigOverridesJson is a third persistence surface for a driver config blob, but it is not part of DraftSnapshot, so this validator cannot see it. --- .../2026-07-15-sql-poll-driver-design.md | 11 +- .../Validation/DraftValidator.cs | 91 +++++++++ .../DraftValidatorSqlSecretTests.cs | 173 ++++++++++++++++++ 3 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorSqlSecretTests.cs diff --git a/docs/plans/2026-07-15-sql-poll-driver-design.md b/docs/plans/2026-07-15-sql-poll-driver-design.md index 5e9df5ec..f125f91f 100644 --- a/docs/plans/2026-07-15-sql-poll-driver-design.md +++ b/docs/plans/2026-07-15-sql-poll-driver-design.md @@ -607,8 +607,15 @@ comment) and the env-overridable ConfigDb connection string: file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so this keeps the factory shape unchanged. -- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it - in display/logging and flags it dev-only. +- **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in + the UI" carve-out is withdrawn (Gitea #498). Redaction only hides a credential that has *already been + written* to the config DB and replicated to every node's artifact cache. `SqlDriverConfigDto` has no + such property and `UnmappedMemberHandling.Skip` drops the key on read, but that protects only the read + path; config blobs are schemaless JSON columns, so nothing stopped the key being **written**. + `DraftValidator.ValidateSqlConnectionStringNotPersisted` now fails the deploy + (`SqlConnectionStringPersisted`) for a `connectionString` key at the top level of a Sql driver's + `DriverConfig` **or** of the `DeviceConfig` of any device beneath it (the two are merged before the DTO + sees them). The key is matched case-insensitively, and the error text never echoes the value. - **Never log the resolved connection string.** Log only provider + server host + database name. - Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD / Kerberos) so no password transits config at all. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 11d6e08c..5b9bc7c7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -41,9 +41,100 @@ public static class DraftValidator ValidateUnsEffectiveLeafUniqueness(draft, errors); ValidateEquipReferenceResolution(draft, errors); ValidateCalculationTags(draft, errors); + ValidateSqlConnectionStringNotPersisted(draft, errors); return errors; } + /// + /// The Sql driver's "a pasted literal connection string is never persisted" guarantee, enforced + /// at the deploy gate (Gitea #498). + /// + /// + /// A Sql driver names its credentials indirectly — connectionStringRef resolves from the + /// environment / secret store at Initialize — so a deployed artifact carries no database password. + /// Until now that guarantee rested entirely on the read path: SqlDriverConfigDto has no + /// connectionString property and UnmappedMemberHandling.Skip drops the key on + /// deserialization. Nothing stopped the key being written. Config blobs are schemaless JSON + /// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON + /// textarea, so an operator pasting {"connectionString":"Server=…;Password=…"} would put a live + /// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by + /// anyone with config access — while the runtime silently ignored it and the driver failed to connect. + /// Discarding a secret on read is not the same as refusing to store it. + /// Checked on both config surfaces a Sql driver reads: the instance's + /// and the of every device + /// beneath it, because the two are merged before the DTO sees them — a credential pasted into the + /// device blob lands in exactly the same place. + /// The key is matched case-insensitively: System.Text.Json binds + /// ConnectionString to a connectionString property by default, so a case variant is the + /// same key, not a different one. Only the top level is scanned — the DTO is flat, so a nested + /// occurrence cannot bind and is not the credential-shaped mistake this rule exists to catch. + /// The message never echoes the value. Validation errors reach the AdminUI, the deploy + /// log and the audit trail; repeating the offending string there would leak the very credential the + /// rule is refusing to store. + /// + private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List errors) + { + const string ForbiddenKey = "connectionString"; + + var sqlInstanceIds = draft.DriverInstances + .Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal)) + .Select(d => d.DriverInstanceId) + .ToHashSet(StringComparer.Ordinal); + if (sqlInstanceIds.Count == 0) return; + + foreach (var d in draft.DriverInstances) + { + if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue; + if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue; + errors.Add(new("SqlConnectionStringPersisted", + $"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " + + "A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " + + "from the environment / secret store at Initialize; a literal connection string here would be " + + "stored in the config database and replicated to every node, and the runtime ignores it anyway. " + + "Remove the key and set 'connectionStringRef'.", + d.DriverInstanceId)); + } + + foreach (var dev in draft.Devices) + { + if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue; + if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue; + errors.Add(new("SqlConnectionStringPersisted", + $"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " + + $"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " + + "the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.", + dev.DeviceId)); + } + } + + /// + /// True when is a JSON object carrying at its top level, + /// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys, + /// and shaping the config JSON is another rule's job. + /// + /// The config blob to inspect. + /// The property name to look for. + /// when the key is present at the top level. + private static bool HasTopLevelKey(string? json, string key) + { + if (string.IsNullOrWhiteSpace(json)) return false; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false; + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true; + } + + return false; + } + catch (System.Text.Json.JsonException) + { + return false; + } + } + /// WP7 Calculation-driver deploy gates. For every tag bound to a Calculation driver: /// /// scriptId existence — the tag's TagConfig.scriptId must be present and resolve to diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorSqlSecretTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorSqlSecretTests.cs new file mode 100644 index 00000000..e2630790 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorSqlSecretTests.cs @@ -0,0 +1,173 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// +/// The Gitea #498 deploy gate: a Sql driver's persisted config may never carry a literal +/// connectionString. The typed DTO already drops the key on read; these pin the +/// write side, which is where a credential would actually land in the config database. +/// +[Trait("Category", "Unit")] +public sealed class DraftValidatorSqlSecretTests +{ + private const string Code = "SqlConnectionStringPersisted"; + + /// A realistic leak: the literal an operator would paste into a raw-JSON config textarea. + private const string LeakedConfig = + """{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}"""; + + private static DriverInstance SqlDriver(string config) => new() + { + DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql", + DriverType = "Sql", DriverConfig = config, + }; + + private static Device Device(string driverInstanceId, string config) => new() + { + DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config, + }; + + private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new() + { + GenerationId = 1, + ClusterId = "c", + DriverInstances = [driver], + Devices = device is null ? [] : [device], + }; + + [Fact] + public void Literal_connectionString_in_DriverConfig_is_a_deploy_error() + { + var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))); + + errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql"); + } + + /// + /// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the + /// problem without repeating the credential it is refusing to store. + /// + [Fact] + public void Error_message_does_not_echo_the_credential() + { + var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code); + + error.Message.ShouldNotContain("hunter2"); + error.Message.ShouldNotContain("Server=sql,1433"); + error.Message.ShouldContain("connectionStringRef"); + } + + /// + /// System.Text.Json binds ConnectionString to a connectionString property by default, so + /// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open. + /// + [Theory] + [InlineData("ConnectionString")] + [InlineData("CONNECTIONSTRING")] + [InlineData("connectionstring")] + public void Key_match_is_case_insensitive(string key) + { + var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}"""; + + DraftValidator.Validate(Draft(SqlDriver(config))) + .ShouldContain(e => e.Code == Code && e.Context == "di-sql"); + } + + /// + /// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted + /// there is the identical leak and must fail the same way. + /// + [Fact] + public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error() + { + var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig)); + + DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1"); + } + + [Fact] + public void A_properly_authored_connectionStringRef_passes() + { + var draft = Draft( + SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""), + Device("di-sql", """{"pollIntervalMs":1000}""")); + + DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code); + } + + /// + /// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate + /// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would + /// be a regression, not defence in depth. + /// + [Fact] + public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule() + { + var modbus = new DriverInstance + { + DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb", + DriverType = "Modbus", DriverConfig = LeakedConfig, + }; + + DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code); + } + + /// + /// A device under a different driver must not be attributed to the Sql instance — the device + /// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices. + /// + [Fact] + public void A_device_under_a_non_Sql_driver_is_not_flagged() + { + var draft = new DraftSnapshot + { + GenerationId = 1, + ClusterId = "c", + DriverInstances = + [ + SqlDriver("""{"connectionStringRef":"DevSql"}"""), + new DriverInstance + { + DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb", + DriverType = "Modbus", DriverConfig = "{}", + }, + ], + Devices = [Device("di-mb", LeakedConfig)], + }; + + DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code); + } + + /// + /// Malformed or non-object config must not throw out of the validator: shaping the JSON is another + /// rule's job, and a parse failure here would take down every other check in the same pass. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not json at all")] + [InlineData("[1,2,3]")] + [InlineData("\"connectionString\"")] + [InlineData("{\"provider\":\"SqlServer\"")] + public void Malformed_config_neither_throws_nor_flags(string config) + { + Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config)))) + .ShouldNotContain(e => e.Code == Code); + } + + /// + /// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and + /// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false + /// positive on, say, a tag blob that happens to describe a connection string. + /// + [Fact] + public void A_nested_connectionString_is_not_flagged() + { + var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}"""; + + DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code); + } +} From 4dc2ad80847c6169a2179f1b85199465337a6d6c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Jul 2026 16:07:53 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat(sql):=20implement=20the=20design=20?= =?UTF-8?q?=C2=A78.1=20catalog=20identifier-validation=20gate=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now ISqlDialect.QuoteIdentifier was the SOLE defence for authored table/column names: an identifier went from the TagConfig blob straight into a command text, bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object, the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered", and the design promised a filter. Both ISqlDialect and SqlServerDialect carried a doc paragraph saying so. SqlCatalogGate + SqlCatalogLoader now resolve every authored identifier against the live catalog at Initialize, and REPLACE it with the catalog's own spelling. The identifier text in an emitted poll query is therefore a string this driver read back out of ListSchemas/ListTables/ListColumns — not operator input. Quoting becomes the backstop it was documented to be. Decisions worth knowing before touching this: - Substitution, not just validation. Matching is exact-ordinal first, then a UNIQUE case-insensitive hit: SQL Server's default collation is CI so case variants have always worked, and rejecting them would break valid deployments. An ambiguous CI match under a case-sensitive collation is refused rather than guessed — picking one would publish another column's data under the operator's node, which is worse than rejecting the tag. - Charset check BEFORE catalog lookup. Each identifier goes through QuoteIdentifier for its rejection rules first, so a name carrying a control or Unicode format character is rejected WITHOUT its value being echoed into a log line (Trojan-Source). A name that passes is safe to render, which is why catalog-miss messages do name it — an operator hunting a typo has to see what they wrote. Both halves are pinned by tests. - A rejected tag keeps its node. It is dropped from the POLLED table but stays in the AUTHORED table, so it still materializes and reads BadNodeIdUnknown — §8.1's specified outcome. My first wiring dropped it from both, which deleted the node instead; an existing test (ReinitializeAsync_recoversFromFaulted) caught it. A status code can only be published by a node that exists, and a missing address-space entry is far harder to diagnose than a Bad quality. - An unreadable catalog FAULTS Initialize; it does not reject every tag. That is the absence of evidence about the tags, not evidence against them — rejecting all of them would serve a confidently-empty address space and send the operator hunting typos that do not exist. Zero visible schemas is treated the same way, because that is exactly what a missing GRANT looks like. Faulting lands DriverInstanceActor in Reconnecting with its retry timer running. - Bounded load: one schema list, one default-schema scalar, then one ListTables per distinct authored schema and one ListColumns per distinct authored relation — never a full catalog enumeration, and nothing after Initialize. Every authored name reaches the catalog queries as a bound @schema/@table parameter, so building the allow-list cannot itself be an injection vector. - ISqlDialect gains DefaultSchemaSql ("SELECT SCHEMA_NAME()"). An unqualified `TagValues` must resolve in whatever schema the SERVER reports; hardcoding dbo would be a silent lie on any estate that maps service accounts to their own default schema. It is a query, not a constant, because the answer is per-connection. - Accepted v1 limitation: a 3-part db.schema.table (or linked-server name) addresses a catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a message pointing at the fix — expose the data through a view in the connected database. VerifyLivenessAsync's wall-clock pattern is extracted to RunBoundedAsync and reused, so the new I/O gets the same R2-01 / STAB-14 protection rather than a second hand-rolled copy. (It also fixes a latent bug in the extracted code: the OperationCanceledException arm detached the wrong task.) Tests: 24 pure gate tests + 9 end-to-end driver tests against the real SQLite catalog + 3 new live tests against the real SQL Server on 10.100.0.35 (21 in that suite now pass, exercising the real SELECT SCHEMA_NAME() + INFORMATION_SCHEMA path). Verified falsifiable: bypassing the gate turns exactly the three rejection assertions red and leaves the rest green. SqlInjectionRegressionTests is deliberately NOT rewritten to expect BadNodeIdUnknown. It drives SqlPollReader directly, below the gate, and pins the quoting backstop on its own — defence in depth is only worth the name if each layer holds independently. Rewriting those assertions would delete the backstop's only coverage and leave the gate a single point of failure. Its scope note, which said the gate does not exist, is updated to say why it stays where it is. --- .../2026-07-15-sql-poll-driver-design.md | 34 ++ .../ISqlDialect.cs | 26 +- .../SqlCatalog.cs | 137 +++++++ .../SqlCatalogGate.cs | 241 ++++++++++++ .../SqlCatalogLoader.cs | 189 ++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 212 ++++++++++- .../SqlServerDialect.cs | 14 +- .../SqlServerReadTests.cs | 93 +++++ .../SqlCatalogGateDriverTests.cs | 354 ++++++++++++++++++ .../SqlCatalogGateTests.cs | 316 ++++++++++++++++ .../SqlInjectionRegressionTests.cs | 33 +- .../SqliteDialect.cs | 6 + 12 files changed, 1617 insertions(+), 38 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateTests.cs diff --git a/docs/plans/2026-07-15-sql-poll-driver-design.md b/docs/plans/2026-07-15-sql-poll-driver-design.md index f125f91f..0aa81f58 100644 --- a/docs/plans/2026-07-15-sql-poll-driver-design.md +++ b/docs/plans/2026-07-15-sql-poll-driver-design.md @@ -588,6 +588,40 @@ SQL Server client works cross-platform, so `Sql` runs on macOS dev too (unlike G `dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing. + + **Implemented (Gitea #496).** `SqlCatalogGate` + `SqlCatalogLoader`, run from + `SqlDriver.InitializeAsync` after the liveness check and before the driver reports Healthy. Shape, + and the decisions worth knowing before touching it: + + - **The catalog's spelling is substituted, not merely checked.** An accepted identifier is rewritten + to the string the catalog returned, so the text quoted into a poll query is one this driver read + back out of `ListSchemas`/`ListTables`/`ListColumns` — not operator input. Matching is + exact-ordinal first, then a *unique* case-insensitive hit (SQL Server's default collation is CI, so + case variants have always worked and rejecting them would break valid configs); an ambiguous CI + match under a case-sensitive collation is refused rather than guessed, because picking one would + publish another column's data under the operator's node. + - **Charset check first, catalog lookup second.** Each identifier goes through `QuoteIdentifier` for + its rejection rules *before* being looked up, so a name carrying a control or Unicode format + character is rejected **without its value being echoed** into a log line (Trojan-Source). A name + that passes is safe to render, which is why catalog-miss messages *do* name it — an operator + hunting a typo has to see what they wrote. + - **Rejection is per-tag and keeps the node.** The rejected tag is dropped from the *polled* table + but stays in the *authored* table, so it still materializes as a node and reads + `BadNodeIdUnknown` (§8.1's specified outcome) instead of vanishing from the address space. Every + drop is logged at Warning with the tag, the field and the reason. + - **A catalog that cannot be read faults Initialize; it does not reject every tag.** An unreadable + catalog is the *absence* of evidence about the tags, not evidence against them — rejecting all of + them would serve a confidently-empty address space and send the operator hunting typos that do not + exist. Zero visible schemas is treated the same way, because that is what a missing GRANT looks + like. Faulting lands `DriverInstanceActor` in Reconnecting with its retry timer running. + - **Bounded load:** one schema-list query, one default-schema scalar (`ISqlDialect.DefaultSchemaSql`, + added for this — an unqualified `TagValues` must resolve in whatever schema the *server* says, not + a guessed `dbo`), then one `ListTables` per distinct authored schema and one `ListColumns` per + distinct authored relation. It never enumerates the whole catalog, and the load runs under the same + wall-clock bound as the liveness check (R2-01 / STAB-14). + - **Accepted v1 limitation:** a 3-part `db.schema.table` (or a linked-server name) addresses a + catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a + message pointing at the fix — expose the data through a view in the connected database. - **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce with a review checklist item + a unit test that feeds a malicious `keyValue`/`table` (`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier), diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs index 9b15d22e..13f26329 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs @@ -14,11 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// parameterized in SQL, so the few that must appear as text are emitted through /// . Any code path that builds SQL by concatenating an authored tag field /// without going through is a defect (design §8.1). -/// Not yet implemented: design §8.1 also specifies that an authored table/column is -/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier -/// rejects the tag. No such gate exists yet — an identifier reaches -/// straight from the authored TagConfig blob, so quoting is currently the only defence, -/// not a backstop behind an upstream filter. Scrutinise it accordingly. +/// Quoting is the backstop, not the only defence. Design §8.1's catalog gate is +/// implemented: resolves every authored table/column against the live +/// catalog at Initialize and replaces it with the catalog's own spelling, so an identifier +/// reaching on the poll path is a string this driver read back out of +/// / / — not +/// operator input. An identifier that resolves to nothing rejects its tag (which then publishes +/// BadNodeIdUnknown) rather than being quoted into a query against a nonexistent object. /// Public because Driver.Sql.Browser consumes it — the catalog SQL is the browse /// engine, so it is shared rather than duplicated. Implementations own their provider package; /// no provider-specific type appears in this signature ( is the abstract @@ -89,6 +91,20 @@ public interface ISqlDialect /// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters. string ListSchemasSql { get; } + /// + /// Scalar query returning the schema an unqualified object name resolves to for the connecting + /// principal — T-SQL's SELECT SCHEMA_NAME(). Takes no parameters. + /// + /// + /// Needed by : a tag authored as TagValues rather than + /// dbo.TagValues has to be looked up in some schema, and guessing dbo would be a + /// silent lie on any estate that maps its service accounts to their own default schema. Asking the + /// server is the only answer that matches how the poll query itself will resolve the name. + /// It is a query rather than a name because the answer is per-connection, not per-dialect + /// — the same dialect resolves differently for two different logins. + /// + string DefaultSchemaSql { get; } + /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind @schema. string ListTablesSql { get; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs new file mode 100644 index 00000000..16b8def1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs @@ -0,0 +1,137 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// An immutable snapshot of the parts of a database's catalog the authored tags actually name — the +/// allow-list design §8.1 requires identifiers to come from. +/// This type holds catalog strings only. Every name in it was read back out of +/// / / +/// ; nothing an operator typed is ever stored here. That is what +/// lets hand the planner catalog spellings rather than authored ones, so the +/// identifier text in an emitted query is a string the database gave us. +/// Deliberately partial. Only the schemas/tables the authored tags name are loaded — a +/// driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this +/// snapshot therefore means "not found when we looked", which is exactly the question the gate +/// asks. +/// +public sealed class SqlCatalog +{ + private readonly IReadOnlyList _schemas; + + /// Canonical schema → canonical table names in it. + private readonly IReadOnlyDictionary> _tablesBySchema; + + /// Canonical schema.table → that relation's canonical column names. + private readonly IReadOnlyDictionary> _columnsByTable; + + /// Constructs a catalog snapshot. + /// The schema an unqualified object name resolves to for this connection. + /// Every schema the connection can see. + /// Canonical schema → its tables/views. + /// Canonical schema.table → its columns. + internal SqlCatalog( + string defaultSchema, + IReadOnlyList schemas, + IReadOnlyDictionary> tablesBySchema, + IReadOnlyDictionary> columnsByTable) + { + DefaultSchema = defaultSchema; + _schemas = schemas; + _tablesBySchema = tablesBySchema; + _columnsByTable = columnsByTable; + } + + /// The schema an unqualified authored object name is resolved in. + public string DefaultSchema { get; } + + /// Resolves an authored schema name to the catalog's own spelling. + /// The authored schema, or null/blank for the default schema. + /// The catalog's spelling, when resolved. + /// when the schema exists (or the default schema is used). + public bool TryResolveSchema(string? authored, out string canonical) + { + if (string.IsNullOrWhiteSpace(authored)) + { + canonical = DefaultSchema; + return true; + } + + return TryMatch(_schemas, authored, out canonical); + } + + /// Resolves an authored table/view name within a canonical schema. + /// The schema, already resolved through . + /// The authored table or view name. + /// The catalog's spelling, when resolved. + /// when the relation exists in that schema. + public bool TryResolveTable(string canonicalSchema, string authored, out string canonical) + { + canonical = string.Empty; + return _tablesBySchema.TryGetValue(canonicalSchema, out var tables) + && TryMatch(tables, authored, out canonical); + } + + /// Resolves an authored column name within a canonical relation. + /// The resolved schema. + /// The resolved table or view. + /// The authored column name. + /// The catalog's spelling, when resolved. + /// when the column exists on that relation. + public bool TryResolveColumn( + string canonicalSchema, string canonicalTable, string authored, out string canonical) + { + canonical = string.Empty; + return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns) + && TryMatch(columns, authored, out canonical); + } + + /// The dictionary key for a canonical relation. + /// The canonical schema. + /// The canonical table. + /// The composite key. + internal static string QualifiedKey(string schema, string table) => schema + "." + table; + + /// + /// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a + /// unique case-insensitive hit is accepted and its catalog spelling returned. + /// + /// + /// Why case-insensitive at all. SQL Server's default collation is case-insensitive, so + /// num_value and NUM_VALUE genuinely name the same column and both work today. Matching + /// ordinally would reject configurations that have always been valid — a gate that breaks working + /// deployments is not defence in depth. + /// Why exact-first, and why ambiguity fails. On a case-sensitive collation a + /// relation may legitimately carry both Value and value. Preferring the exact match keeps + /// such a config resolving to what the operator wrote, and refusing to choose between two + /// case-insensitive candidates is the only safe answer — silently picking one would publish a different + /// column's data under the operator's node, which is worse than rejecting the tag. + /// + /// The catalog spellings to match against. + /// The authored name. + /// The catalog's spelling, when matched. + /// on an unambiguous match. + internal static bool TryMatch(IReadOnlyList candidates, string authored, out string canonical) + { + foreach (var candidate in candidates) + { + if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue; + canonical = candidate; + return true; + } + + canonical = string.Empty; + var matches = 0; + foreach (var candidate in candidates) + { + if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue; + if (++matches > 1) + { + canonical = string.Empty; + return false; + } + + canonical = candidate; + } + + return matches == 1; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs new file mode 100644 index 00000000..3485da74 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs @@ -0,0 +1,241 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log +/// shape and the caller's tests can assert on the reason. +/// +/// The rejected tag's identity — its RawPath, which is trusted structure, not blob content. +/// The field that failed (e.g. ValueColumn). +/// An operator-actionable explanation, safe to log. +public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason); + +/// The outcome of applying a to a set of authored tags. +/// +/// The surviving definitions, rewritten to catalog spellings. Safe to hand to +/// . +/// +/// Every tag the gate refused, in input order. +public sealed record SqlCatalogGateResult( + IReadOnlyList Accepted, + IReadOnlyList Rejected); + +/// +/// Design §8.1's identifier gate: validates every authored table/column against the live catalog +/// before it can be quoted into SQL text, and replaces it with the catalog's own spelling. +/// +/// +/// What this closes. Until this existed, was the +/// sole defence: an identifier went from the authored TagConfig blob straight into a command text, +/// bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object, +/// the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered", +/// and the design promised a filter. With the gate in place the identifier text in an emitted query is a +/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather +/// than the whole story. +/// Charset first, catalog second. Each identifier is passed through +/// before it is looked up, purely for its rejection rules +/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that +/// fails the charset check is rejected without its value being echoed, so nothing carrying a bidi +/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has +/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they +/// must, or an operator has no way to find the typo. +/// Rejection is per-tag, never driver-wide. A tag naming a dropped column is dropped from the +/// driver's table, so its RawPath resolves to nothing and it publishes +/// — design §8.1's specified outcome — while every other tag +/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver +/// down" rule the tag-table build already follows. +/// +public static class SqlCatalogGate +{ + /// + /// The most name parts this gate can validate. A three-part db.schema.table (or a + /// four-part linked-server name) addresses a catalog this connection's INFORMATION_SCHEMA + /// cannot see, so it cannot be allow-listed. + /// + private const int MaxNameParts = 2; + + /// + /// Applies to , returning the accepted tags + /// with catalog-spelled identifiers and the rejected ones with reasons. + /// + /// The authored definitions, as parsed from their TagConfig blobs. + /// The catalog snapshot to validate against. + /// Supplies the identifier charset rules via . + /// The accepted and rejected sets. + /// A required argument is null. + public static SqlCatalogGateResult Apply( + IEnumerable definitions, SqlCatalog catalog, ISqlDialect dialect) + { + ArgumentNullException.ThrowIfNull(definitions); + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(dialect); + + var accepted = new List(); + var rejected = new List(); + + foreach (var definition in definitions) + { + ArgumentNullException.ThrowIfNull(definition); + if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection)) + accepted.Add(canonical!); + else + rejected.Add(rejection!); + } + + return new SqlCatalogGateResult(accepted, rejected); + } + + /// Resolves one definition's identifiers, or explains the first failure. + private static bool TryCanonicalize( + SqlTagDefinition definition, + SqlCatalog catalog, + ISqlDialect dialect, + out SqlTagDefinition? canonical, + out SqlCatalogRejection? rejection) + { + canonical = null; + rejection = null; + + if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection)) + return false; + + // Every identifier-bearing field, paired with its name for the rejection message. A field the tag's + // model does not use is null and simply skipped — the planner enforces which are required. + var resolved = new Dictionary(StringComparer.Ordinal); + foreach (var (field, authored) in new (string Field, string? Authored)[] + { + (nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn), + (nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn), + (nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn), + (nameof(SqlTagDefinition.ColumnName), definition.ColumnName), + (nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn), + (nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp), + }) + { + if (string.IsNullOrWhiteSpace(authored)) + { + resolved[field] = authored; + continue; + } + + if (!IsRenderableIdentifier(authored, dialect)) + { + rejection = new SqlCatalogRejection(definition.Name, field, + $"the authored {field} is not a usable identifier (it is over-long, or contains control " + + "or Unicode format characters); the value is withheld from this message because it " + + "cannot be safely rendered"); + return false; + } + + if (!catalog.TryResolveColumn(schema, table, authored, out var column)) + { + rejection = new SqlCatalogRejection(definition.Name, field, + $"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " + + "column under a case-sensitive collation)"); + return false; + } + + resolved[field] = column; + } + + canonical = definition with + { + Table = SqlCatalog.QualifiedKey(schema, table), + KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)], + ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)], + TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)], + ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)], + RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)], + RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)], + }; + return true; + } + + /// Splits and resolves the authored table to a canonical schema + relation. + private static bool TryResolveRelation( + SqlTagDefinition definition, + SqlCatalog catalog, + ISqlDialect dialect, + out string schema, + out string table, + out SqlCatalogRejection? rejection) + { + schema = string.Empty; + table = string.Empty; + rejection = null; + const string Field = nameof(SqlTagDefinition.Table); + + if (string.IsNullOrWhiteSpace(definition.Table)) + { + rejection = new SqlCatalogRejection(definition.Name, Field, + "the tag has no 'table'; author the table or view to read"); + return false; + } + + var parts = definition.Table.Split('.'); + if (parts.Length > MaxNameParts) + { + rejection = new SqlCatalogRejection(definition.Name, Field, + $"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " + + "validated against this connection's catalog, so a cross-database or linked-server name " + + "cannot be allow-listed; expose the data through a view in this database instead"); + return false; + } + + var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null; + var authoredTable = parts[^1]; + + foreach (var part in parts) + { + if (IsRenderableIdentifier(part, dialect)) continue; + rejection = new SqlCatalogRejection(definition.Name, Field, + "the authored table name has a part that is not a usable identifier (empty, over-long, or " + + "containing control or Unicode format characters); the value is withheld from this message " + + "because it cannot be safely rendered"); + return false; + } + + if (!catalog.TryResolveSchema(authoredSchema, out schema)) + { + rejection = new SqlCatalogRejection(definition.Name, Field, + $"schema '{authoredSchema}' does not exist (or matches more than one schema under a " + + "case-sensitive collation)"); + return false; + } + + if (!catalog.TryResolveTable(schema, authoredTable, out table)) + { + rejection = new SqlCatalogRejection(definition.Name, Field, + $"table or view '{authoredTable}' does not exist in schema '{schema}'" + + (authoredSchema is null + ? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)" + : string.Empty)); + return false; + } + + return true; + } + + /// + /// True when the dialect's quoting rules accept — i.e. it is safe both to + /// embed in SQL and to render into a log line or an AdminUI label. + /// + /// + /// Uses as the charset authority rather than duplicating its + /// rules, so the two can never disagree about what a legal identifier is. The quoted result is + /// discarded: only whether it threw is interesting here. + /// + private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect) + { + try + { + dialect.QuoteIdentifier(identifier); + return true; + } + catch (ArgumentException) + { + return false; + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs new file mode 100644 index 00000000..f0802333 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs @@ -0,0 +1,189 @@ +using System.Data; +using System.Data.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Loads the slice the authored tags need, over one connection, using only the +/// dialect's catalog SQL (design §8.1). +/// +/// +/// Bounded by what is authored, not by what exists. One query for the schema list, one for +/// the default schema, then one per distinct authored schema and +/// one per distinct authored relation. A driver polling three +/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a +/// warehouse. +/// Every parameter is bound. Authored names reach the catalog queries as +/// @schema/@table parameters — the same discipline the schema browser follows — so loading +/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path. +/// Failures throw; they do not degrade to an empty catalog. An empty catalog would reject +/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all +/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has +/// learned nothing — and must fail its Initialize so the driver retries rather than serving a +/// confidently-empty address space. +/// +internal static class SqlCatalogLoader +{ + /// Column alias projects. + private const string SchemaColumn = "TABLE_SCHEMA"; + + /// Column alias projects. + private const string TableNameColumn = "TABLE_NAME"; + + /// Column alias projects. + private const string ColumnNameColumn = "COLUMN_NAME"; + + /// + /// Reads the catalog slice covering . + /// + /// An already-open connection. Not disposed here; the caller owns it. + /// Supplies the catalog SQL. + /// The distinct authored table strings, as written in the blobs. + /// Per-command server-side backstop. + /// Cancellation token for the operation. + /// The loaded catalog. + /// The connection can see no schemas at all. + internal static async Task LoadAsync( + DbConnection connection, + ISqlDialect dialect, + IReadOnlyCollection authoredTables, + TimeSpan commandTimeout, + CancellationToken cancellationToken) + { + var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds)); + + var schemas = await QueryColumnAsync( + connection, dialect.ListSchemasSql, SchemaColumn, + static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false); + + // Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on + // that basis would report an authoring fault for what is actually a grant problem, and would send the + // operator to the wrong system — the same misdiagnosis the driver's health classification avoids. + if (schemas.Count == 0) + throw new InvalidOperationException( + "the catalog returned no schemas at all; the connecting principal most likely cannot read " + + "the catalog views, which is a grant problem rather than a tag-authoring one"); + + var defaultSchema = await ReadDefaultSchemaAsync( + connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false); + + var tablesBySchema = new Dictionary>(StringComparer.Ordinal); + var columnsByTable = new Dictionary>(StringComparer.Ordinal); + + // Resolving here goes through SqlCatalog.TryMatch — the SAME matcher the gate will use — so the + // loader and the gate can never disagree about which relation an authored name resolved to. Loading + // one table and validating against another would be a silent wrong-column defect. + foreach (var authored in authoredTables) + { + if (string.IsNullOrWhiteSpace(authored)) continue; + + var parts = authored.Split('.'); + if (parts.Length > 2) continue; // the gate rejects these by name; nothing to load. + + var authoredTable = parts[^1]; + string schema; + if (parts.Length == 2) + { + if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue; + } + else + { + schema = defaultSchema; + } + + if (!tablesBySchema.TryGetValue(schema, out var tables)) + { + tables = await QueryColumnAsync( + connection, dialect.ListTablesSql, TableNameColumn, + command => Bind(command, "@schema", schema), + timeoutSeconds, cancellationToken).ConfigureAwait(false); + tablesBySchema[schema] = tables; + } + + if (!SqlCatalog.TryMatch(tables, authoredTable, out var table)) continue; + + var key = SqlCatalog.QualifiedKey(schema, table); + if (columnsByTable.ContainsKey(key)) continue; + + columnsByTable[key] = await QueryColumnAsync( + connection, dialect.ListColumnsSql, ColumnNameColumn, + command => + { + Bind(command, "@schema", schema); + Bind(command, "@table", table); + }, + timeoutSeconds, cancellationToken).ConfigureAwait(false); + } + + return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable); + } + + /// + /// Reads the connection's default schema, falling back to the sole visible schema when the dialect's + /// query answers null/blank. + /// + /// + /// A null answer is possible (a login with no default schema mapped). Falling back to the single + /// visible schema keeps the common one-schema database working; with several visible schemas there is + /// no defensible guess, so unqualified names simply will not resolve and the gate says so by name. + /// + private static async Task ReadDefaultSchemaAsync( + DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = dialect.DefaultSchemaSql; + command.CommandTimeout = timeoutSeconds; + var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + var schema = value is null or DBNull ? null : value.ToString(); + return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema; + } + } + + /// Runs one catalog query and projects a single string column from every row. + private static async Task> QueryColumnAsync( + DbConnection connection, + string sql, + string columnName, + Action bind, + int timeoutSeconds, + CancellationToken cancellationToken) + { + var results = new List(); + + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + command.CommandTimeout = timeoutSeconds; + bind(command); + + var reader = await command + .ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + var ordinal = -1; + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (ordinal < 0) ordinal = reader.GetOrdinal(columnName); + if (reader.IsDBNull(ordinal)) continue; + var value = reader.GetValue(ordinal)?.ToString(); + if (!string.IsNullOrWhiteSpace(value)) results.Add(value); + } + } + } + + return results; + } + + /// Binds one catalog-query parameter — the only way an authored name reaches the database here. + private static void Bind(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.String; + parameter.Value = value; + command.Parameters.Add(parameter); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index 9d2396bf..e99c3355 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -74,7 +74,23 @@ public sealed class SqlDriver private IReadOnlyDictionary _tagsByRawPath = new Dictionary(StringComparer.Ordinal); - /// Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss. + /// + /// The subset of that survived the design §8.1 catalog gate, with every + /// identifier rewritten to the catalog's own spelling. This — not the authored table — is what a + /// read or a poll resolves against, so an identifier that never appeared in the catalog can never + /// reach a query. + /// Why the two tables are separate. A tag the gate rejected must still exist as a + /// node: §8.1's specified outcome is that it publishes + /// , and a status code can only be published by a node + /// that is there. Dropping rejected tags from the authored table too would delete the node instead, + /// turning a diagnosable Bad quality into a silently missing address-space entry — a strictly worse + /// failure for the operator trying to find their typo. + /// Swapped atomically, for the same reason the authored table is. + /// + private IReadOnlyDictionary _polledByRawPath = + new Dictionary(StringComparer.Ordinal); + + /// Resolves a read/subscribe RawPath to its catalog-validated definition, or a miss. private readonly EquipmentTagRefResolver _resolver; /// @@ -182,8 +198,18 @@ public sealed class SqlDriver /// The current authored-tag snapshot. Read through a barrier — swaps it. private IReadOnlyDictionary Tags => Volatile.Read(ref _tagsByRawPath); - /// The resolver's lookup: RawPath → authored definition, or null on a miss. - private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath); + /// + /// The catalog-validated snapshot. Read through a barrier — swaps it. + /// + private IReadOnlyDictionary PolledTags => Volatile.Read(ref _polledByRawPath); + + /// + /// The resolver's lookup: RawPath → catalog-validated definition, or null on a miss. + /// Deliberately reads rather than : a tag the gate + /// rejected must miss here so the reader publishes + /// , exactly as design §8.1 specifies. + /// + private SqlTagDefinition? Lookup(string rawPath) => PolledTags.GetValueOrDefault(rawPath); // ---- IDriver lifecycle ---- @@ -196,11 +222,16 @@ public sealed class SqlDriver /// I/O, and on failure this method records and rethrows — /// DriverInstanceActor reads a throw as InitializeFailed and lands in Reconnecting with its /// retry timer running, which is exactly the recovery a database that is merely down needs. + /// The liveness check is then followed by the design §8.1 catalog gate + /// (), which is the second and last piece of I/O. It runs after + /// liveness because it needs a working connection, and before the driver reports Healthy because the + /// tag table it publishes must be the validated one — a poll must never see an unvalidated + /// identifier. /// /// The driver configuration JSON (unused; see remarks). /// Cancellation token for the operation. /// A task that represents the asynchronous operation. - /// The database could not be reached. + /// The database could not be reached, or its catalog could not be read. public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); @@ -234,6 +265,36 @@ public sealed class SqlDriver throw new InvalidOperationException(message, ex); } + // Separate try from liveness so the operator surface names the stage that actually failed: "could + // not reach the database" and "reached it but could not read its catalog" send an operator to + // different places, and the second is usually a GRANT rather than an outage. + try + { + await ApplyCatalogGateAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + WriteHealth(new DriverHealth(DriverState.Unknown, null, null)); + throw; + } + catch (Exception ex) + { + // Same discipline as above: exception TYPE only on the operator surface, full exception to the + // log sink. A catalog read that fails is NOT evidence the tags are wrong — it is the absence of + // evidence — so this faults the driver (and retries) rather than rejecting every tag and serving + // a confidently-empty address space. + var message = + $"Sql driver '{_driverInstanceId}' reached {Endpoint} but could not read its catalog to " + + $"validate authored tables/columns: {ex.GetType().Name}. Check that the connecting principal " + + "can read the catalog views."; + _logger.LogError(ex, + "Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.", + _driverInstanceId, Endpoint); + WriteHealth(new DriverHealth(DriverState.Faulted, null, message)); + TransitionHostTo(HostState.Stopped); + throw new InvalidOperationException(message, ex); + } + WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); TransitionHostTo(HostState.Running); _logger.LogInformation( @@ -594,6 +655,94 @@ public sealed class SqlDriver } Volatile.Write(ref _tagsByRawPath, table); + + // Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a + // previous generation's validated table in place) means a Reinitialize whose gate then fails cannot + // keep polling the OLD identifiers against a database whose schema may be exactly what changed. + Volatile.Write(ref _polledByRawPath, new Dictionary(StringComparer.Ordinal)); + } + + // ---- catalog gate (design §8.1) ---- + + /// + /// Validates every authored identifier against the live catalog and republishes the tag table with + /// catalog spellings, dropping the tags that do not resolve (design §8.1). + /// + /// + /// Why this must run before the driver reports Healthy. The table this swaps in is what + /// every poll resolves against. Running it later — or in the background — would leave a window in + /// which an unvalidated identifier could be quoted into a query, which is the entire hole the gate + /// exists to close. + /// A dropped tag is not a driver fault. It disappears from the table, so its RawPath + /// resolves to nothing and it publishes — one operator + /// typo must not stop the other tags on that database, the same rule + /// follows for a malformed blob. Each drop is logged at Warning with the + /// tag, the field and the reason, because a node that silently goes Bad with nothing in the log is + /// unsupportable. + /// No tags ⇒ no I/O. A driver with nothing authored has nothing to validate, and issuing + /// catalog queries to prove that would be a round-trip that can only fail. + /// Bounded by the same wall-clock discipline as the liveness check (R2-01 / STAB-14): the work + /// runs on the thread pool so a provider implementing the async path synchronously blocks a pool + /// thread rather than wedging Initialize forever with no retry. + /// + /// The caller's token. + /// A task that completes when the tag table has been validated and republished. + private async Task ApplyCatalogGateAsync(CancellationToken cancellationToken) + { + var authored = Tags; + if (authored.Count == 0) return; + + var tables = authored.Values + .Select(definition => definition.Table) + .Where(table => !string.IsNullOrWhiteSpace(table)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var catalog = await RunBoundedAsync( + token => LoadCatalogAsync(tables, token), + _options.OperationTimeout, + "the catalog queries did not return", + cancellationToken).ConfigureAwait(false); + + var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect); + + foreach (var rejection in result.Rejected) + { + _logger.LogWarning( + "Sql tag '{Tag}' rejected by the catalog gate: {Field} — {Reason}. It will publish " + + "BadNodeIdUnknown until the tag or the database is corrected. Driver={DriverInstanceId}", + rejection.RawPath, rejection.Field, rejection.Reason, _driverInstanceId); + } + + var validated = new Dictionary(result.Accepted.Count, StringComparer.Ordinal); + foreach (var definition in result.Accepted) validated[definition.Name] = definition; + + // Only the POLLED table is replaced. The authored table is left intact so every authored tag keeps + // its node and a rejected one publishes BadNodeIdUnknown rather than vanishing (see _polledByRawPath). + Volatile.Write(ref _polledByRawPath, validated); + + _logger.LogInformation( + "Sql driver {DriverInstanceId} catalog gate accepted {Accepted} of {Total} authored tag(s) " + + "across {Tables} table(s) on {Endpoint}.", + _driverInstanceId, result.Accepted.Count, authored.Count, tables.Length, Endpoint); + } + + /// Opens one connection and reads the catalog slice the authored tables need. + private async Task LoadCatalogAsync( + IReadOnlyCollection authoredTables, CancellationToken cancellationToken) + { + var connection = _factory.CreateConnection() + ?? throw new InvalidOperationException( + $"the {_dialect.Provider} provider factory returned no connection."); + + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = _connectionString; + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + return await SqlCatalogLoader + .LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken) + .ConfigureAwait(false); + } } // ---- liveness ---- @@ -611,18 +760,50 @@ public sealed class SqlDriver /// /// The caller's token. /// A task that completes when the database has answered. - private async Task VerifyLivenessAsync(CancellationToken cancellationToken) + private Task VerifyLivenessAsync(CancellationToken cancellationToken) => + RunBoundedAsync( + async token => { await PingAsync(token).ConfigureAwait(false); return true; }, + _options.CommandTimeout, + "the liveness statement did not return", + cancellationToken); + + /// + /// Runs under a hard wall-clock , on the thread pool, + /// converting a breach into a worded from . + /// + /// + /// The R2-01 / STAB-14 shape, shared by the two pieces of I/O Initialize performs. It exists + /// because a token is not a deadline: some ADO.NET providers implement the async path synchronously, + /// and a wedged socket can hang inside the provider's own cancellation handshake. Without an + /// independent wall clock, DriverInstanceActor's init task would never complete and the driver + /// would sit in Connecting forever with no retry — the exact frozen-peer wedge the S7 driver shipped. + /// Both the linked CTS deadline and an outer wait are used, because + /// each covers a case the other does not: the CTS handles a provider that does honour + /// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of + /// its own connection and disposes it; observes its eventual fault. + /// + /// The work's result type. + /// The operation to bound. Receives the deadline-linked token. + /// The wall-clock allowance. + /// Sentence fragment naming the operation, e.g. "the catalog queries did not return". + /// The caller's token. + /// The work's result. + /// The budget elapsed first. + private static async Task RunBoundedAsync( + Func> work, + TimeSpan budget, + string what, + CancellationToken cancellationToken) { - var budget = _options.CommandTimeout; var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Task work; + Task running; try { deadline.CancelAfter(budget); - work = Task.Run( + running = Task.Run( async () => { - try { await PingAsync(deadline.Token).ConfigureAwait(false); } + try { return await work(deadline.Token).ConfigureAwait(false); } finally { deadline.Dispose(); } }, CancellationToken.None); @@ -636,20 +817,18 @@ public sealed class SqlDriver try { - await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false); + return await running.WaitAsync(budget, cancellationToken).ConfigureAwait(false); } catch (TimeoutException) { - Detach(work); - throw new TimeoutException( - $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms."); + Detach(running); + throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms."); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { // Our deadline fired and the provider DID honour the linked token. - Detach(work); - throw new TimeoutException( - $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms."); + Detach(running); + throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms."); } } @@ -752,6 +931,7 @@ public sealed class SqlDriver { await _poll.DisposeAsync().ConfigureAwait(false); Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal)); + Volatile.Write(ref _polledByRawPath, new Dictionary(StringComparer.Ordinal)); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs index e666856f..ca38e8bb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs @@ -44,6 +44,12 @@ public sealed class SqlServerDialect : ISqlDialect public string ListSchemasSql => "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; + /// + /// SCHEMA_NAME() with no argument returns the calling principal's default schema — the one an + /// unqualified object name resolves in, which is precisely the question the catalog gate asks. + /// + public string DefaultSchemaSql => "SELECT SCHEMA_NAME()"; + /// public string ListTablesSql => "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " + @@ -70,9 +76,11 @@ public sealed class SqlServerDialect : ISqlDialect /// control-character rule. /// The rejection messages deliberately do not echo the offending value — it is untrusted /// input and this exception's text reaches the driver log. - /// This is currently the only defence, not a backstop. Design §8.1 specifies that an - /// identifier is validated against the live catalog before reaching here; that gate is not implemented - /// yet, so an authored TagConfig table/column arrives unfiltered. + /// This is a backstop, not the only defence. Design §8.1's catalog gate + /// () resolves an authored table/column against the live catalog at + /// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here + /// is a string read back out of the catalog. The rules below still run — the gate uses them as its own + /// charset filter, and they must hold for any future caller that has no catalog to check against. /// /// The bare, unquoted identifier part. /// The bracket-quoted identifier. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs index c3fe2e99..ebf2d212 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs @@ -475,6 +475,99 @@ public sealed class SqlServerReadTests // ---- helpers ---- + // ---- design §8.1 catalog gate (Gitea #496), against a real INFORMATION_SCHEMA ---- + + /// + /// The gate's live proof: an authored column that does not exist is refused by the allow-list at + /// Initialize, so it never reaches a query — and its neighbour on the same table keeps reading. + /// + /// + /// Only a real SQL Server exercises the real SELECT SCHEMA_NAME() + INFORMATION_SCHEMA + /// path the loader depends on; the SQLite-backed suites prove the logic but not that T-SQL's catalog + /// answers the shape the loader expects. + /// + [Fact] + public async Task CatalogGate_realSqlServer_rejectsAnUnknownColumn_andSparesItsNeighbour() + { + SkipUnlessLive(); + + var bogus = Tag("Sql/Line1/Bogus", new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable), + ["keyColumn"] = SqlPollServerFixture.KeyColumn, + ["keyValue"] = SqlPollServerFixture.PresentKey, + ["valueColumn"] = "no_such_column", + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + }); + + await using var driver = await StartAsync( + KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), bogus); + + // An authoring typo is not a database fault: the driver stays Healthy. + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + var snapshots = await driver.ReadAsync( + ["Sql/Line1/Speed", "Sql/Line1/Bogus"], TestContext.Current.CancellationToken); + + SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue(); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + } + + /// + /// T-SQL object names are case-insensitive under the default collation, so a case-variant tag has + /// always been valid and must keep working — the gate substitutes the catalog's spelling rather than + /// rejecting the operator's. + /// + [Fact] + public async Task CatalogGate_realSqlServer_acceptsACaseVariantIdentifierAndStillReads() + { + SkipUnlessLive(); + + var upper = Tag("Sql/Line1/Speed", new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable).ToUpperInvariant(), + ["keyColumn"] = SqlPollServerFixture.KeyColumn.ToUpperInvariant(), + ["keyValue"] = SqlPollServerFixture.PresentKey, + ["valueColumn"] = SqlPollServerFixture.ValueColumn.ToUpperInvariant(), + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn.ToUpperInvariant(), + }); + + await using var driver = await StartAsync(upper); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(); + } + + /// + /// A cross-database name cannot be validated from this connection's catalog, so it is refused rather + /// than quoted into a query — the documented v1 limitation, asserted so it stays deliberate. + /// + [Fact] + public async Task CatalogGate_realSqlServer_rejectsAThreePartName() + { + SkipUnlessLive(); + + var threePart = Tag("Sql/Line1/Remote", new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = $"otherdb.{SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable)}", + ["keyColumn"] = SqlPollServerFixture.KeyColumn, + ["keyValue"] = SqlPollServerFixture.PresentKey, + ["valueColumn"] = SqlPollServerFixture.ValueColumn, + }); + + await using var driver = await StartAsync(threePart); + + (await driver.ReadAsync(["Sql/Line1/Remote"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + } + /// Skips the calling test when the live-server gate is not configured or not reachable. private void SkipUnlessLive() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs new file mode 100644 index 00000000..9236f3e3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs @@ -0,0 +1,354 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// The design §8.1 catalog gate end-to-end through , against the real SQLite +/// catalog the poll fixture creates — real ListSchemas/ListTables/ListColumns +/// round-trips, not a hand-built . +/// The two facts that matter most here are the ones a pure unit test cannot show: that a +/// rejected tag still has a node and reads BadNodeIdUnknown (rather than silently +/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead +/// of rejecting every tag. +/// +public sealed class SqlCatalogGateDriverTests +{ + private const string DriverInstanceId = "sql-gate"; + private const string ConfigJson = """{"provider":"SqlServer"}"""; + + [Fact] + public async Task A_tag_naming_a_real_table_and_columns_polls_normally() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem(); + SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(); + snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue); + } + + /// + /// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a + /// query — but its node still exists and reads BadNodeIdUnknown. Dropping the node instead would + /// turn a diagnosable Bad quality into a missing address-space entry. + /// + [Fact] + public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, + KvEntry("Speed", SqlitePollFixture.PresentKey), + KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column")); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + // The driver is healthy: an authoring typo is not a database fault. + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + // The node is still materialized... + var capture = new CapturingBuilder(); + await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); + capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true); + + // ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown. + var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None); + SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue(); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + } + + [Fact] + public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable")); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + (await driver.ReadAsync(["Bogus"], CancellationToken.None)) + .ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + } + + /// + /// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real + /// query that failed only once the connection was open; now it never reaches a query at all. + /// + [Fact] + public async Task A_hostile_table_name_never_reaches_a_query() + { + using var fixture = new SqlitePollFixture(); + var logger = new CapturingLogger(); + await using var driver = NewDriver( + fixture, logger, + KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--")); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + (await driver.ReadAsync(["Evil"], CancellationToken.None)) + .ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + + // The fixture's table is untouched — proven by a second tag still reading through it. + await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await honest.InitializeAsync(ConfigJson, CancellationToken.None); + var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem(); + SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(); + + logger.Entries.ShouldContain(e => + e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal)); + } + + /// + /// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the + /// field and the reason. + /// + [Fact] + public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason() + { + using var fixture = new SqlitePollFixture(); + var logger = new CapturingLogger(); + await using var driver = NewDriver( + fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu")); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + var warning = logger.Entries + .Where(e => e.Level == LogLevel.Warning) + .Select(e => e.Message) + .ShouldHaveSingleItem(); + warning.ShouldContain("Bogus"); + warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn)); + warning.ShouldContain("num_valeu"); + } + + /// + /// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must + /// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry + /// catalog strings rather than operator input. + /// + [Fact] + public async Task A_case_variant_identifier_is_accepted_and_polls() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, + KvEntry( + "Speed", SqlitePollFixture.PresentKey, + table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(), + valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant())); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem(); + SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(); + snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue); + } + + /// + /// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would + /// be a round-trip that can only fail. + /// + [Fact] + public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// The fail-closed rule. A catalog that cannot be read is the ABSENCE of evidence about the + /// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space + /// and send the operator hunting typos that do not exist; faulting Initialize instead lands + /// DriverInstanceActor in Reconnecting with its retry timer running. + /// + [Fact] + public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag() + { + using var fixture = new SqlitePollFixture(); + await using var driver = new SqlDriver( + new SqlDriverOptions + { + RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)], + OperationTimeout = TimeSpan.FromSeconds(15), + CommandTimeout = TimeSpan.FromSeconds(10), + }, + DriverInstanceId, + new CatalogSqlOverride("SELECT this is not valid sql"), + fixture.ConnectionString, + factory: fixture.Factory, + logger: NullLogger.Instance); + + var thrown = await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + + // The operator surface names the stage that actually failed — "reached it but could not read the + // catalog" and "could not reach it" send an operator to different systems. + thrown.Message.ShouldContain("catalog"); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + /// + /// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject + /// every tag for an authoring fault the operator does not have. + /// + [Fact] + public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize() + { + using var fixture = new SqlitePollFixture(); + await using var driver = new SqlDriver( + new SqlDriverOptions + { + RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)], + OperationTimeout = TimeSpan.FromSeconds(15), + CommandTimeout = TimeSpan.FromSeconds(10), + }, + DriverInstanceId, + new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"), + fixture.ConnectionString, + factory: fixture.Factory, + logger: NullLogger.Instance); + + await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + // ---- helpers ---- + + /// + /// Delegates every member to except , so the + /// catalog-load failure modes can be driven against an otherwise-real dialect. + /// + /// + /// A decorator rather than a subclass: is sealed, and even if it were not, + /// new-hiding a property would leave interface dispatch calling the base — the substitution + /// would silently not happen and both tests below would pass for the wrong reason. + /// + private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect + { + private static readonly SqliteDialect Inner = new(); + + public SqlProvider Provider => Inner.Provider; + + public System.Data.Common.DbProviderFactory Factory => Inner.Factory; + + public string LivenessSql => Inner.LivenessSql; + + public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix; + + public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix; + + public string ListSchemasSql { get; } = listSchemasSql; + + public string DefaultSchemaSql => Inner.DefaultSchemaSql; + + public string ListTablesSql => Inner.ListTablesSql; + + public string ListColumnsSql => Inner.ListColumnsSql; + + public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident); + + public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType); + } + + private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags) + => NewDriver(fixture, new CapturingLogger(), rawTags); + + private static SqlDriver NewDriver( + SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags) + => new( + new SqlDriverOptions + { + RawTags = rawTags, + OperationTimeout = TimeSpan.FromSeconds(15), + CommandTimeout = TimeSpan.FromSeconds(10), + }, + DriverInstanceId, + new SqliteDialect(), + fixture.ConnectionString, + factory: fixture.Factory, + logger: logger); + + /// One authored raw tag, with the table and value column overridable so the gate can be exercised. + private static RawTagEntry KvEntry( + string rawPath, + string keyValue, + string? table = null, + string? valueColumn = null) + => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$""" + { + "driver": "Sql", + "model": "KeyValue", + "table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}", + "keyColumn": "{{SqlitePollFixture.KeyColumn}}", + "keyValue": "{{keyValue}}", + "valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}", + "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}" + } + """), WriteIdempotent: false); + + /// Records everything the driver streams into the address space. + private sealed class CapturingBuilder : IAddressSpaceBuilder + { + /// The variables registered, in order. + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add((browseName, attributeInfo)); + return new Handle(attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullReference) : IVariableHandle + { + public string FullReference => fullReference; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink(); + + private sealed class Sink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } + } + + /// Records every log record, level + rendered message. + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + => Entries.Add((logLevel, formatter(state, exception))); + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() { } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateTests.cs new file mode 100644 index 00000000..51d9acbf --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateTests.cs @@ -0,0 +1,316 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// The design §8.1 catalog gate (Gitea #496), tested as a pure function over a hand-built +/// . The end-to-end behaviour against a real catalog — and the proof that a +/// rejected tag keeps its node and publishes BadNodeIdUnknown — lives in +/// . +/// +public sealed class SqlCatalogGateTests +{ + private static readonly SqliteDialect Dialect = new(); + + /// A catalog with one schema, two relations, and deliberately mixed-case column spellings. + private static SqlCatalog Catalog(string defaultSchema = "dbo") => new( + defaultSchema, + ["dbo", "mes"], + new Dictionary>(StringComparer.Ordinal) + { + ["dbo"] = ["TagValues", "LatestStatus"], + ["mes"] = ["Orders"], + }, + new Dictionary>(StringComparer.Ordinal) + { + ["dbo.TagValues"] = ["tag_name", "num_value", "sample_ts"], + ["dbo.LatestStatus"] = ["station_id", "oven_temp", "pressure", "sample_ts"], + ["mes.Orders"] = ["order_id", "qty"], + }); + + private static SqlTagDefinition KeyValueTag( + string table = "dbo.TagValues", + string keyColumn = "tag_name", + string valueColumn = "num_value", + string? timestampColumn = "sample_ts") => + new("plant/sql/Speed", SqlTagModel.KeyValue, table, + KeyColumn: keyColumn, KeyValue: "Line1.Speed", + ValueColumn: valueColumn, TimestampColumn: timestampColumn); + + private static SqlCatalogGateResult Apply(params SqlTagDefinition[] tags) => + SqlCatalogGate.Apply(tags, Catalog(), Dialect); + + [Fact] + public void A_fully_resolvable_tag_is_accepted() + { + var result = Apply(KeyValueTag()); + + result.Rejected.ShouldBeEmpty(); + result.Accepted.Count.ShouldBe(1); + } + + /// + /// The heart of §8.1: what reaches the planner must be a string the catalog gave us, not the string an + /// operator typed. Authoring every identifier in the wrong case proves the substitution actually + /// happens rather than the input merely being waved through. + /// + [Fact] + public void Accepted_identifiers_are_rewritten_to_the_catalogs_own_spelling() + { + var authored = KeyValueTag( + table: "DBO.TAGVALUES", keyColumn: "TAG_NAME", valueColumn: "Num_Value", timestampColumn: "SAMPLE_TS"); + + var accepted = Apply(authored).Accepted.ShouldHaveSingleItem(); + + accepted.Table.ShouldBe("dbo.TagValues"); + accepted.KeyColumn.ShouldBe("tag_name"); + accepted.ValueColumn.ShouldBe("num_value"); + accepted.TimestampColumn.ShouldBe("sample_ts"); + // Identity and bound VALUES are untouched — the gate rewrites identifiers only. + accepted.Name.ShouldBe(authored.Name); + accepted.KeyValue.ShouldBe(authored.KeyValue); + } + + [Fact] + public void An_unqualified_table_resolves_in_the_default_schema() + { + var accepted = Apply(KeyValueTag(table: "TagValues")).Accepted.ShouldHaveSingleItem(); + + accepted.Table.ShouldBe("dbo.TagValues"); + } + + /// + /// Guessing dbo would be a silent lie on an estate that maps service accounts to their own + /// default schema, so the gate must resolve an unqualified name in whatever schema the server reports. + /// + [Fact] + public void An_unqualified_table_follows_a_non_dbo_default_schema() + { + var catalog = Catalog(defaultSchema: "mes"); + + var result = SqlCatalogGate.Apply([KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)], catalog, Dialect); + + result.Accepted.ShouldHaveSingleItem().Table.ShouldBe("mes.Orders"); + } + + [Fact] + public void An_unknown_table_rejects_the_tag() + { + var rejection = Apply(KeyValueTag(table: "dbo.NoSuchTable")).Rejected.ShouldHaveSingleItem(); + + rejection.RawPath.ShouldBe("plant/sql/Speed"); + rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); + rejection.Reason.ShouldContain("NoSuchTable"); + } + + [Fact] + public void An_unknown_schema_rejects_the_tag() + { + var rejection = Apply(KeyValueTag(table: "nope.TagValues")).Rejected.ShouldHaveSingleItem(); + + rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); + rejection.Reason.ShouldContain("nope"); + } + + [Fact] + public void An_unknown_column_rejects_the_tag_and_names_the_field() + { + var rejection = Apply(KeyValueTag(valueColumn: "no_such_column")).Rejected.ShouldHaveSingleItem(); + + rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); + rejection.Reason.ShouldContain("no_such_column"); + rejection.Reason.ShouldContain("dbo.TagValues"); + } + + /// + /// A table in the right catalog but the wrong schema must not resolve — otherwise the gate would + /// allow-list a column set from a relation the query will never read. + /// + [Fact] + public void A_table_from_another_schema_does_not_resolve_unqualified() + { + var rejection = Apply(KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)) + .Rejected.ShouldHaveSingleItem(); + + rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); + } + + /// + /// A cross-database or linked-server name addresses a catalog this connection cannot enumerate, so it + /// cannot be allow-listed. Rejecting is the fail-closed answer, and the message says what to do instead. + /// + [Fact] + public void A_three_part_name_is_rejected_with_an_actionable_message() + { + var rejection = Apply(KeyValueTag(table: "otherdb.dbo.TagValues")).Rejected.ShouldHaveSingleItem(); + + rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table)); + rejection.Reason.ShouldContain("3-part"); + rejection.Reason.ShouldContain("view"); + } + + /// + /// The injection shape the whole gate exists for: a hostile identifier must be refused by the + /// allow-list, not merely quoted into a query against a nonexistent object. + /// + [Theory] + [InlineData("TagValues]; DROP TABLE TagValues--")] + [InlineData("'; DROP TABLE TagValues--")] + [InlineData("TagValues WHERE 1=1 OR 1=1")] + public void A_hostile_table_name_is_rejected_by_the_allow_list(string table) + { + Apply(KeyValueTag(table: table)).Rejected.ShouldHaveSingleItem() + .Field.ShouldBe(nameof(SqlTagDefinition.Table)); + } + + [Theory] + [InlineData("num_value]; DROP TABLE TagValues--")] + [InlineData("(SELECT password FROM users)")] + public void A_hostile_column_name_is_rejected_by_the_allow_list(string column) + { + Apply(KeyValueTag(valueColumn: column)).Rejected.ShouldHaveSingleItem() + .Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); + } + + /// + /// A name carrying a control or Unicode format character cannot be safely rendered into a log line + /// (the Trojan-Source class, CVE-2021-42574), so the gate rejects it without echoing it — the + /// charset check runs before the catalog lookup precisely so no such string can reach a message. + /// + /// + /// Driven against , not the test-only : the + /// charset rules belong to the dialect (the gate delegates to + /// rather than duplicating them), and SQLite's rules + /// deliberately stop at control characters. Asserting SQL Server's rules means using SQL Server's + /// dialect — the first draft of this test asserted them against SQLite's and failed for that reason. + /// + [Theory] + [InlineData("num\u0000value")] // Cc — embedded NUL + [InlineData("num\u0009value")] // Cc — tab; truncates or corrupts a logged statement + [InlineData("num\u202Evalue")] // Cf — right-to-left override + [InlineData("num\u200Bvalue")] // Cf — zero-width space + public void An_unrenderable_identifier_is_rejected_without_being_echoed(string column) + { + var result = SqlCatalogGate.Apply( + [KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect()); + + var rejection = result.Rejected.ShouldHaveSingleItem(); + rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); + rejection.Reason.ShouldContain("withheld"); + rejection.Reason.ShouldNotContain(column); + } + + /// + /// An over-long name cannot name a real object and is likewise withheld, rather than pasting an + /// unbounded slab of operator input into a log line. + /// + [Fact] + public void An_over_long_identifier_is_rejected_without_being_echoed() + { + var column = new string('x', SqlServerDialect.MaxIdentifierLength + 1); + + var result = SqlCatalogGate.Apply( + [KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect()); + + result.Rejected.ShouldHaveSingleItem().Reason.ShouldContain("withheld"); + } + + /// + /// The complement, and the reason the charset check is not simply "withhold everything": a name that + /// IS safely renderable gets echoed, because an operator hunting a typo has to see what they wrote. + /// + [Fact] + public void A_renderable_but_unknown_identifier_IS_echoed_so_the_typo_is_findable() + { + var rejection = Apply(KeyValueTag(valueColumn: "num_valeu")).Rejected.ShouldHaveSingleItem(); + + rejection.Reason.ShouldContain("num_valeu"); + rejection.Reason.ShouldNotContain("withheld"); + } + + /// + /// On a case-sensitive collation a relation may legitimately carry both Value and value. + /// Picking one would publish a different column's data under the operator's node, so the only safe + /// answer is to refuse — but an EXACT match must still win, or a valid config would break. + /// + [Fact] + public void An_ambiguous_case_insensitive_column_match_is_rejected_but_an_exact_match_still_wins() + { + var catalog = new SqlCatalog( + "dbo", + ["dbo"], + new Dictionary>(StringComparer.Ordinal) { ["dbo"] = ["T"] }, + new Dictionary>(StringComparer.Ordinal) + { + ["dbo.T"] = ["k", "Value", "value"], + }); + + var ambiguous = new SqlTagDefinition( + "p/Ambiguous", SqlTagModel.KeyValue, "dbo.T", + KeyColumn: "k", KeyValue: "x", ValueColumn: "VALUE"); + SqlCatalogGate.Apply([ambiguous], catalog, Dialect).Rejected.ShouldHaveSingleItem() + .Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn)); + + var exact = ambiguous with { Name = "p/Exact", ValueColumn = "value" }; + SqlCatalogGate.Apply([exact], catalog, Dialect).Accepted.ShouldHaveSingleItem() + .ValueColumn.ShouldBe("value"); + } + + /// + /// One operator typo must not stop the other tags on the same database — the same rule the tag-table + /// build already follows for a malformed blob. + /// + [Fact] + public void A_rejected_tag_does_not_take_its_healthy_neighbours_with_it() + { + var good = KeyValueTag() with { Name = "plant/sql/Good" }; + var bad = KeyValueTag(valueColumn: "typo") with { Name = "plant/sql/Bad" }; + + var result = Apply(good, bad); + + result.Accepted.ShouldHaveSingleItem().Name.ShouldBe("plant/sql/Good"); + result.Rejected.ShouldHaveSingleItem().RawPath.ShouldBe("plant/sql/Bad"); + } + + /// Every identifier-bearing field is checked, not just the two the key-value model happens to use. + [Fact] + public void The_wide_row_models_identifier_fields_are_validated_too() + { + var selectorTypo = new SqlTagDefinition( + "p/Oven", SqlTagModel.WideRow, "dbo.LatestStatus", + ColumnName: "oven_temp", RowSelectorColumn: "no_such_selector", RowSelectorValue: "7"); + Apply(selectorTypo).Rejected.ShouldHaveSingleItem() + .Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorColumn)); + + var orderTypo = new SqlTagDefinition( + "p/Newest", SqlTagModel.WideRow, "dbo.LatestStatus", + ColumnName: "oven_temp", RowSelectorTopByTimestamp: "no_such_ts"); + Apply(orderTypo).Rejected.ShouldHaveSingleItem() + .Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorTopByTimestamp)); + + var columnTypo = new SqlTagDefinition( + "p/Bad", SqlTagModel.WideRow, "dbo.LatestStatus", + ColumnName: "no_such_column", RowSelectorColumn: "station_id", RowSelectorValue: "7"); + Apply(columnTypo).Rejected.ShouldHaveSingleItem() + .Field.ShouldBe(nameof(SqlTagDefinition.ColumnName)); + + var ok = new SqlTagDefinition( + "p/Ok", SqlTagModel.WideRow, "dbo.LatestStatus", + ColumnName: "OVEN_TEMP", RowSelectorColumn: "STATION_ID", RowSelectorValue: "7"); + var accepted = Apply(ok).Accepted.ShouldHaveSingleItem(); + accepted.ColumnName.ShouldBe("oven_temp"); + accepted.RowSelectorColumn.ShouldBe("station_id"); + accepted.RowSelectorValue.ShouldBe("7"); // a bound value, never canonicalized + } + + /// An absent optional identifier is not a rejection — only a present, unresolvable one is. + [Fact] + public void An_absent_optional_timestamp_column_is_left_alone() + { + var accepted = Apply(KeyValueTag(timestampColumn: null)).Accepted.ShouldHaveSingleItem(); + + accepted.TimestampColumn.ShouldBeNull(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs index 784b3dd7..a49be22d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs @@ -12,16 +12,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// identifier — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes /// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag /// cannot alter the database; the seed table is intact after every hostile poll. -/// Scope note — what this suite does NOT assume. Design §8.1 also specifies a catalog gate: -/// validate every authored table/column against INFORMATION_SCHEMA before quoting it, so an unknown -/// identifier rejects the tag. No such gate exists in the driver yet (see the "Not yet -/// implemented" note on ), and no task in this workstream builds one. So a hostile -/// identifier here is not rejected as BadNodeIdUnknown — it is bracket-/double-quoted into a -/// single nonexistent identifier, the query then fails after the connection opened, and the tag Bad-codes -/// as a query failure (). This suite proves the payload -/// is inert — it does not execute and the table survives — which is the guarantee the code -/// actually makes. The catalog gate is a separate follow-up; a test that pretended it existed would be -/// asserting fiction. +/// Scope note — this suite deliberately tests the layer BELOW the catalog gate. Design +/// §8.1's catalog gate now exists (Gitea #496): validates every authored +/// table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier +/// is rejected up front and its tag reads — proven by +/// SqlCatalogGateDriverTests. +/// The tests here construct a directly, with hand-built +/// definitions that never pass through the gate, and that is the point: defence in depth is only worth +/// the name if each layer holds on its own. These assertions pin the quoting backstop — that +/// even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a +/// nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure +/// (), and the seed table survives. Rewriting them to +/// expect BadNodeIdUnknown would delete the only coverage the backstop has and leave the gate as a +/// single point of failure. /// public sealed class SqlInjectionRegressionTests { @@ -68,9 +71,11 @@ public sealed class SqlInjectionRegressionTests using var fixture = new SqlitePollFixture(); var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); - // A table name carrying a statement terminator + DROP. There is NO catalog gate, so this is not - // rejected up front — it is quoted into one nonexistent identifier. The query fails (no such table), - // the connection having opened, so the tag Bad-codes as a query failure. The DROP never runs. + // A table name carrying a statement terminator + DROP. The reader is driven directly, so the + // catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one + // nonexistent identifier, the query fails (no such table) with the connection already open, so the + // tag Bad-codes as a query failure. The DROP never runs. In the assembled driver the gate rejects + // this first and the tag reads BadNodeIdUnknown instead — see SqlCatalogGateDriverTests. var reader = NewReader(fixture, new SqlTagDefinition( Name: "Evil", @@ -83,7 +88,7 @@ public sealed class SqlInjectionRegressionTests var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); - // Bad-coded — but as a QUERY failure, not BadNodeIdUnknown (there is no identifier-catalog gate). + // Bad-coded as a QUERY failure, not BadNodeIdUnknown — this reader was built without the gate. SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError); // The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs index 8a7a7527..a3ec1ca6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs @@ -64,6 +64,12 @@ public sealed class SqliteDialect : ISqlDialect /// public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA"; + /// + /// SQLite has no per-principal default schema, so an unqualified name always resolves in + /// — the same single schema reports. + /// + public string DefaultSchemaSql => $"SELECT '{MainSchema}'"; + /// /// Tables + views from sqlite_schema (the modern name for sqlite_master), with the /// internal sqlite_* objects filtered out and the type folded onto the From 154171f48cdca5bb4cc92a439b0a4cde9b4aa013 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Jul 2026 20:20:06 -0400 Subject: [PATCH 4/4] =?UTF-8?q?test(runtime):=20fix=20the=20intermittent?= =?UTF-8?q?=20Runtime.Tests=20failure=20=E2=80=94=204=20ordering/logic=20d?= =?UTF-8?q?efects=20+=20the=20presence-budget=20class=20(#500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported symptom was "Runtime.Tests occasionally reports Failed: 1" with no test name. Instrumenting 30 full-assembly runs reproduced it at 13% (4 runs) and showed it was never ONE flaky test: five failures across three distinct tests in that batch, and five more distinct tests over the verification rounds that followed. Two hypotheses were tested and DISPROVED by measurement before anything was changed: - Cluster-formation timeout. Every test in this assembly forms a real single-node Akka cluster over TCP in RuntimeActorTestBase's constructor and waits 5 s for MemberStatus.Up — 219 formations per run. Instrumented: idle max 960 ms, under-load max 1470 ms, zero timeouts. A 3.4x margin; not the cause. - Ephemeral-port exhaustion from that bind churn. TIME_WAIT measured at ~0 throughout. Not the cause. FOUR GENUINE ORDERING/LOGIC DEFECTS (none is a timeout): 1. VirtualTagHostActorTests.ApplyVirtualTags_respawns_child_when_plan_changes_in_place The test loops to accept RegisterInterest/UnregisterInterest in EITHER order — and then asserts on mux.LastSender, which DEPENDS on that order. Under the [Register, Unregister] interleaving LastSender is the DYING child and the assertion fails. Now captures the sender of the Register message as it arrives. Fully deterministic; no timing involved. (Swept the other four LastSender sites: all drain the Unregister first, so their ordering is fixed. Only this one was unsafe.) 2. DriverHostActorWriteRoutingTests.Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath ApplyAck marks the end of the APPLY, not the point a write can succeed: the child is still in Connecting, which deliberately fast-fails writes ("driver not connected"), and the NodeId->driver reverse map has not been pushed. Now retries until accepted. This does not weaken the assertion — every rejection branch replies WITHOUT reaching the driver, so Writes.Count.ShouldBe(1) still means exactly what it did. 3. HistorianAdapterActorTests.Redundancy_snapshot_without_local_node_... One 500 ms constant served both presence budgets (AwaitAssert) and absence windows (ExpectNoMsg). Different quantities that happened to share a number, so the presence budget could not be raised without slowing every absence check. Split into AssertTimeout (5 s) and Settle (500 ms). 4. ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks Waited on `outbox == 0`, which is ALSO true before the first append — so the poll could win the race against the recorder and return having observed the INITIAL state, after which the CallCount>=2 check outside the block failed against a recorder that had not run. Both conditions are now polled together with the retry count as the discriminator. (The preceding test in the same file documents this exact trap.) THE PRESENCE-BUDGET CLASS: After those four, three consecutive 30-run rounds each still failed once — on a DIFFERENT test, in a DIFFERENT file, every time (OpcUaPublishActorApplyFailureTests 2 s, OpcUaPublishActorRebuildTests 2 s, OpcUaPublishActorTests 500 ms). That is a distribution, not a defect, and fixing it one test at a time was the wrong method. RuntimeActorTestBase now carries a documented PresenceBudget (15 s) and 57 sub-3 s AwaitAssert/AwaitCondition durations across four files route through it. Why this is safe rather than sloppy: a presence budget is an upper bound before giving up, not a wait — these helpers poll and return the instant the condition holds. Raising one costs nothing on the happy path and CANNOT make a genuinely failing assertion pass; it only changes how quickly a real breakage reports. Absence windows are the opposite (the elapsed time IS the assertion) and were deliberately left untouched with their own short, individually-calibrated literals. ScriptedAlarmHostActorTests is a special case, diagnosed rather than merely raised: its 8 s budget was sized for message passing but actually waits on a REAL Roslyn compile (ApplyScriptedAlarms -> ScriptedAlarmEngine.LoadAsync compiles every predicate). Cold script compiles are the slowest and most variable thing in the assembly, so that class gets 30 s with the reason recorded. Verification: 30 consecutive full-assembly runs, 30 passed / 0 failed, against a 13% baseline. Full solution builds; all 41 unit-test projects green. NOT fixed here, filed as #501: DriverHostActorNativeAlarmAckRoutingTests:90 and :116 use AwaitAssert for an ABSENCE assertion, so they return instantly and prove nothing. They are excluded from the budget change on purpose — a bigger number does not fix them, and rewriting them to settle-then-assert may legitimately turn them red. --- .../DriverHostActorWriteRoutingTests.cs | 50 ++++++++++++++--- .../Harness/RuntimeActorTestBase.cs | 19 +++++++ .../Health/PeerProbeSupervisorTests.cs | 26 ++++----- .../ContinuousHistorizationRecorderTests.cs | 22 ++++++-- .../Historian/HistorianAdapterActorTests.cs | 36 +++++++++---- .../OpcUaPublishActorApplyFailureTests.cs | 7 ++- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 32 +++++------ .../OpcUa/OpcUaPublishActorTests.cs | 54 +++++++++---------- .../ScriptedAlarmHostActorTests.cs | 18 ++++++- .../VirtualTags/VirtualTagActorTests.cs | 2 +- .../VirtualTags/VirtualTagHostActorTests.cs | 12 ++++- 11 files changed, 199 insertions(+), 79 deletions(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index e5ac2a8c..baf11d1a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -55,11 +55,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); - var asker = CreateTestProbe(); // The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); + RouteWriteUntilAccepted(actor, $"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns); - asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => { recorder.Writes.Count.ShouldBe(1); @@ -79,10 +77,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); - var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref); + RouteWriteUntilAccepted(actor, $"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw); - asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => { recorder.Writes.Count.ShouldBe(1); @@ -91,6 +87,48 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase }, duration: Timeout); } + /// + /// Routes a write, retrying until the host accepts it, and fails with the host's own rejection + /// Reason if it never does. + /// + /// + /// Why a retry rather than a single Tell. ApplyAck — which + /// waits for — marks the end of the apply, not the point at + /// which a write can succeed. Several things still have to happen after it: the spawned child has to + /// finish InitializeAsync and leave Connecting (that state deliberately fast-fails + /// writes with "driver not connected"), and the host has to push the NodeId→driver reverse map that + /// resolves the write. Telling the write immediately therefore races the setup, which is why this + /// assertion failed roughly 1 run in 30 of the fully parallel assembly — and never once in 60 + /// consecutive runs of this class alone. + /// Why retrying does not weaken the assertion. Every rejection branch — the Primary + /// gate, an unresolved reverse map, "driver not running", and the child's own pre-Connected + /// fast-fail — replies without reaching the driver. A rejected attempt records nothing, so + /// the caller's Writes.Count.ShouldBe(1) still means exactly what it did before: the accepted + /// write reached the driver exactly once. + /// The failure message carries the last Reason so a genuine regression is diagnosable + /// rather than surfacing as a bare "expected True". + /// + /// The driver-host actor. + /// The ns-qualified NodeId string, as the node manager passes it. + /// The value to write. + /// The address-space realm the NodeId belongs to. + private void RouteWriteUntilAccepted( + IActorRef actor, string nodeId, object value, AddressSpaceRealm realm) + { + var lastReason = "(no reply)"; + AwaitAssert( + () => + { + var asker = CreateTestProbe(); + actor.Tell(new DriverHostActor.RouteNodeWrite(nodeId, value, realm), asker.Ref); + var result = asker.ExpectMsg(TimeSpan.FromSeconds(1)); + lastReason = result.Reason ?? "(none)"; + result.Success.ShouldBeTrue($"host kept rejecting the write; last reason: {lastReason}"); + }, + duration: Timeout, + interval: TimeSpan.FromMilliseconds(50)); + } + /// On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver /// receives NO write — the primary gate fires before the reverse-map lookup. [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs index f2fb1f81..be6fd400 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs @@ -11,6 +11,25 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; /// public abstract class RuntimeActorTestBase : TestKit { + /// + /// Shared upper bound for a presence wait (AwaitAssert / AwaitCondition). + /// + /// + /// A budget, not a delay. These helpers poll and return the instant the condition holds, + /// so the value is how long to wait before giving up. Raising it costs nothing on the happy path and + /// cannot make a genuinely failing assertion pass — it only changes how quickly a real + /// breakage is reported. That is the opposite of an absence window (ExpectNoMsg, + /// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on + /// every run. Absence windows keep their own short, individually-calibrated literals and must not be + /// routed through here. + /// Why it exists (Gitea #500). This assembly runs 44 Akka test classes roughly 14-way + /// parallel, each with its own ActorSystem. Budgets of 300–500 ms sized on an + /// idle machine are comfortable in isolation and marginal under that contention: three consecutive + /// 30-run verification rounds each surfaced a different test failing on one, in three + /// different files. Fixing them one at a time was chasing a distribution rather than a defect. + /// + protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15); + /// Gets the Akka test HOCON configuration string for single-node cluster setup. protected static string AkkaTestHocon => @" akka { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs index 4acd8e79..5937cd3e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs @@ -57,7 +57,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Adm, RedundancyRole.Detached))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies the child for a departed peer is stopped when the next snapshot omits it. @@ -71,11 +71,11 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies a single-node snapshot (just the local node) spawns no children. @@ -88,7 +88,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies a previously-removed peer is respawned when it re-appears, without an @@ -103,17 +103,17 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Locks in the stale-Terminated guard: when an OLD (already-replaced) child's @@ -132,27 +132,27 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); - AwaitAssert(() => spawned.Count.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); + AwaitAssert(() => spawned.Count.ShouldBe(1), duration: PresenceBudget); var oldRef = spawned[0]; // Drop the peer -> child #0 stopped, ChildCount back to 0. sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); // Re-add the SAME peer -> a NEW child #1 (the FRESH ref) is spawned. sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); - AwaitAssert(() => spawned.Count.ShouldBe(2), duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); + AwaitAssert(() => spawned.Count.ShouldBe(2), duration: PresenceBudget); // Now deliver a STALE Terminated for the OLD ref. The current child for Peer is the fresh // child #1, so ref-equality finds no match and the supervisor must leave ChildCount at 1. sup.Tell(new Terminated(oldRef, existenceConfirmed: true, addressTerminated: false)); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs index 0a32804e..b665e221 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs @@ -295,10 +295,24 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit // The first drain returns false (entry retained); after the backoff the retry drain succeeds // and acks, truncating the outbox to 0. - await AwaitAssertAsync(async () => - Assert.Equal(0, await outbox.CountAsync(default)), TimeSpan.FromSeconds(5)); - - Assert.True(writer.CallCount >= 2, "the writer must have been called at least twice (a retry happened)"); + // + // BOTH conditions are polled together, and the retry count is the load-bearing one. An empty + // outbox is NOT a distinguishing observation: it is equally true BEFORE the append lands, so + // waiting on it alone can be satisfied by the initial state and return before the recorder has + // done anything at all. That is exactly what happened intermittently under a fully parallel + // assembly run — the poll won the race against the first append, the wait returned immediately, + // and the CallCount check that used to sit outside the block then failed against a recorder that + // had not yet run. (The same trap is called out on the preceding test, which guards against it by + // pairing its count with a writer observation.) + await AwaitAssertAsync( + async () => + { + Assert.True( + writer.CallCount >= 2, + "the writer must have been called at least twice (a retry happened)"); + Assert.Equal(0, await outbox.CountAsync(default)); + }, + TimeSpan.FromSeconds(5)); } [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs index 4167c31d..d58ff74d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs @@ -23,9 +23,27 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase /// The local node id the gating tests construct the adapter with. private static readonly NodeId LocalNode = new("node-A"); - /// A short window we allow the fire-and-forget enqueue to land within. + /// + /// The window an absence assertion waits before concluding nothing arrived + /// (ExpectNoMsg). Its length is a calibration decision — long enough that a message which + /// was going to arrive would have — so it is deliberately NOT generous. + /// private static readonly TimeSpan Settle = TimeSpan.FromMilliseconds(500); + /// + /// The budget a presence assertion may take to become true (AwaitAssert). + /// + /// + /// Separate from on purpose, though they once shared its 500 ms. The two are + /// different quantities that merely had the same number: a presence budget is an upper bound before + /// giving up, and AwaitAssert returns the instant the condition holds, so a generous value + /// costs nothing in the passing case and can never make a genuinely failing assertion pass. An + /// absence window is the opposite — every millisecond is spent on every run. Conflating them meant + /// the enqueue assertions could only be given more headroom by slowing every ExpectNoMsg in + /// the class, so they kept a 500 ms budget that a fully parallel assembly run occasionally missed. + /// + private static readonly TimeSpan AssertTimeout = TimeSpan.FromSeconds(5); + /// Thread-safe fake sink that records every call. private sealed class RecordingSink : IAlarmHistorianSink { @@ -101,7 +119,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase actor.Tell(SampleEvent()); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Secondary suppression (T2): when the cached local role is Secondary, the adapter MUST @@ -145,7 +163,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase TellRedundancyRole(actor, RedundancyRole.Primary); actor.Tell(SampleEvent()); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Absent-node default-historize (T5): a snapshot that mentions only a DIFFERENT node @@ -174,7 +192,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase actor.Tell(SampleEvent()); // Local role is still unknown ⇒ default-historize path: sink must record exactly one enqueue. - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Builds an (the shape published on the alerts @@ -222,7 +240,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase e.Severity.ShouldBe(AlarmSeverity.High); e.Comment.ShouldBe("note"); }, - Settle); + AssertTimeout); } /// Secondary suppression for alerts (T7): a Secondary node must NOT historize a transition @@ -252,7 +270,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase TellRedundancyRole(actor, RedundancyRole.Primary); actor.Tell(SampleTransition()); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Per-alarm opt-out (T8b): a Primary node must NOT historize a transition whose @@ -286,7 +304,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase TellRedundancyRole(actor, RedundancyRole.Primary); actor.Tell(SampleTransition(historizeToAveva: null)); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Severity buckets (T9): the OPC UA 1–1000 numeric severity on the transition maps onto @@ -308,7 +326,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase AwaitAssert( () => sink.Events.ShouldHaveSingleItem().Severity.ShouldBe(expected), - Settle); + AssertTimeout); } /// Rolling-restart null default (T10): an old-format transition deserialized by Akka's JSON @@ -326,6 +344,6 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase AwaitAssert( () => sink.Events.ShouldHaveSingleItem().AlarmTypeName.ShouldBe("AlarmCondition"), - Settle); + AssertTimeout); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 33b4153c..4bd67911 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -48,11 +48,16 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + // A PRESENCE budget, so it is an upper bound before giving up rather than a wait: the helper polls + // and returns the instant the meter fires, so a generous value costs nothing on the happy path and + // can never make a genuinely failing assertion pass. Two seconds covered an idle machine but not a + // fully parallel assembly run, where this deploy→rebuild→throw chain (two DB-backed applies) has to + // share 14 cores with 40-odd other Akka test classes; it failed there roughly 1 run in 30. AwaitAssert(() => { recorder.Total.ShouldBeGreaterThanOrEqualTo(1); recorder.WithTag("kind", "rebuild").ShouldBeGreaterThanOrEqualTo(1); - }, duration: TimeSpan.FromSeconds(2)); + }, duration: TimeSpan.FromSeconds(15)); } /// A clean rebuild does NOT increment the apply-failed meter (Info-only happy path). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index a51410b4..8a8c364f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -45,7 +45,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase sink.Calls.ShouldContain("EF:eq-1"); sink.Calls.ShouldContain("EF:eq-2"); sink.Calls.ShouldContain("NA:line-1"); - }, duration: TimeSpan.FromSeconds(2)); + }, duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); } @@ -85,7 +85,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild var callsAfterFirst = sink.Calls.Count; @@ -107,7 +107,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } /// @@ -132,7 +132,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact)); // The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised… - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); // …and the address-space-wiping raw-sink fallback was NOT taken. sink.RebuildCalls.ShouldBe(0); } @@ -176,7 +176,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } /// @@ -197,7 +197,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); } @@ -230,7 +230,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice. // t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1". - AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: PresenceBudget); sinkA.RebuildCalls.ShouldBe(0); // t-main (MAIN cluster) must NOT leak onto the SITE-A node. sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1"); @@ -249,7 +249,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: PresenceBudget); sinkM.RebuildCalls.ShouldBe(0); sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1"); } @@ -331,7 +331,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); var calls = sink.Calls.ToList(); @@ -356,7 +356,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:")); @@ -365,7 +365,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); // No new NodeAdded announcement was raised for the rebuild-kind deploy. sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst); } @@ -388,7 +388,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); var callsAfterFirst = sink.Calls.Count; // The next deploy arrives while the ConfigDb is briefly unreachable. @@ -423,13 +423,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); // eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); - AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: PresenceBudget); } /// An whose CreateDbContext can be made to @@ -552,7 +552,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd // (area + line + equipment all added), so NO full rebuild; the folder is materialised directly. actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and @@ -563,7 +563,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase AwaitAssert(() => { sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South")); - }, duration: TimeSpan.FromSeconds(2)); + }, duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // the rename did NOT force a full rebuild } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index e10e9329..a6f10ef3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -53,7 +53,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase sink.Values[0].Value.ShouldBe(3.14); sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good); sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot. @@ -73,7 +73,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase sink.Alarms[0].State.Active.ShouldBeTrue(); sink.Alarms[0].State.Acknowledged.ShouldBeFalse(); sink.Alarms[0].State.Severity.ShouldBe((ushort)700); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// #477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm. @@ -93,7 +93,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi"); q.Quality.ShouldBe(OpcUaQuality.Bad); q.Realm.ShouldBe(AddressSpaceRealm.Raw); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// Builds a test with sensible defaults so each test @@ -117,7 +117,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } /// Verifies that forwards to the @@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes")); sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false)); sink.ModelChanges.ShouldContain("EQ-1"); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level. @@ -161,7 +161,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the very first computed ServiceLevel is always published even when it is @@ -186,7 +186,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader. @@ -210,7 +210,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(snapshot); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that RedundancyStateChanged for secondary publishes 100. @@ -231,7 +231,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(snapshot); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 250 for a healthy primary role-leader @@ -256,7 +256,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 240 for a healthy non-leader secondary @@ -282,7 +282,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 100 when the DB is unreachable @@ -307,7 +307,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)100), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 200 for a stale snapshot when the DB is @@ -335,7 +335,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that a detached local node publishes 0 (the calculator does not model @@ -362,7 +362,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); // Now detach — expect the guard to drive ServiceLevel down to 0. actor.Tell(new RedundancyStateChanged( @@ -374,7 +374,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that an actively-observed, recent peer probe of MY endpoint that came back @@ -403,7 +403,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies branch (3) of OpcUaProbeOk(): a peer's NEGATIVE verdict about this node @@ -447,7 +447,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase // Healthy (250), NOT 0 — proves an aged negative verdict does not demote. AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that with no peer probe result ever received, OpcUaProbeOk() defaults @@ -474,7 +474,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that a later Ok==true peer probe supersedes an earlier Ok==false @@ -503,7 +503,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that a peer probe result about a DIFFERENT node is ignored — it does not @@ -531,7 +531,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies the legacy back-compat seam: with no DB-health probe wired, the handler @@ -553,7 +553,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the periodic HealthTick Asks the local @@ -607,7 +607,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus). @@ -629,7 +629,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness @@ -654,7 +654,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less @@ -677,7 +677,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); actor.Tell(new RedundancyStateChanged( Nodes: new[] @@ -688,7 +688,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default @@ -714,7 +714,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Stub DB-health probe actor that answers diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 86686e86..27861788 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -28,7 +28,23 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; /// public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase { - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8); + /// + /// Upper bound for the presence waits in this class (ExpectMsg / FishForMessage). + /// + /// + /// Sized for a real Roslyn compilation, not for message passing. + /// ApplyScriptedAlarms drives ScriptedAlarmEngine.LoadAsync, which compiles every + /// alarm predicate through ScriptEvaluator.Compile; the RegisterInterest these tests + /// wait on is only sent once that finishes. A cold C# script compile is slow and highly variable — + /// far more so than anything else in this assembly — and Roslyn's caches are per-process, so the + /// first class to compile pays the worst of it. Under a fully parallel assembly run, sharing 14 + /// cores with 40-odd other Akka test classes, 8 s was occasionally missed (Gitea #500). + /// Raising it is free on the happy path: these waits return the instant the message arrives, + /// so the value is an upper bound before giving up, not a delay, and it cannot make a genuinely + /// failing expectation pass. The absence assertions in this class deliberately keep their own + /// short literals — there the elapsed time is the point. + /// + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); /// Plan whose predicate compares the single tag "M.T" against 90 — enabled by default. private static EquipmentScriptedAlarmPlan Plan( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs index a63d9ed5..38fb64bd 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs @@ -79,7 +79,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase entry.Message.ShouldContain("syntax error"); entry.ScriptId.ShouldBe("script-7"); entry.VirtualTagId.ShouldBe("vt-1"); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); // 02/S13: the failure now ALSO degrades the node — with no declared dependencyRefs the // inputs-ready gate is vacuously satisfied, so a Bad EvaluationResult reaches the parent (this diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index d74f7276..e61de11a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -260,7 +260,15 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase // The old child is stopped (PostStop ⇒ UnregisterInterest) and a new one spawned // (PreStart ⇒ RegisterInterest on "B"). Both messages arrive at the mux probe; order between // the dying child's PostStop and the new child's PreStart is not guaranteed, so accept either. + // + // Because the order is genuinely undefined, the SENDER of the RegisterInterest must be captured + // as that message is received. Reading mux.LastSender after the loop instead would read the + // sender of whichever message happened to arrive SECOND — the dying child when the interleaving + // is [Register, Unregister] — so the identity assertion below silently depended on the very + // ordering this loop exists to tolerate. That made it fail roughly 10% of the time under a + // fully parallel assembly run, and never in isolation. DependencyMuxActor.RegisterInterest? reg2 = null; + IActorRef? secondChild = null; var sawUnregister = false; for (var i = 0; i < 2; i++) { @@ -269,6 +277,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase { case DependencyMuxActor.RegisterInterest r: reg2 = r; + secondChild = mux.LastSender; // sender OF THIS message, not of the last one seen break; case DependencyMuxActor.UnregisterInterest: sawUnregister = true; @@ -282,7 +291,8 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase reg2.TagRefs.ShouldNotContain("A"); // The replacement is a different actor ref than the original (auto-named, so no collision). - mux.LastSender.ShouldNotBe(firstChild); + secondChild.ShouldNotBeNull(); + secondChild.ShouldNotBe(firstChild); } ///