merge: Sql driver follow-ups #496/#497/#498 + the Runtime.Tests flake #500 (fix/sql-driver-followups)
v2-ci / build (push) Successful in 4m15s
v2-ci / unit-tests (push) Failing after 15m14s

Four commits closing the three follow-ups the Sql poll driver left behind, plus the
intermittent Runtime.Tests failure found while verifying them.

- #497 corrects 16 wrong OPC UA status-code constants across 6 drivers (the issue named
  3) and adds StatusCodeParityTests, a reflection guard checking every driver's
  hard-coded uint against the pinned SDK. That guard is what found the other 13.
- #498 fails the deploy when a Sql driver's persisted config carries a literal
  connectionString — the write-side half of a guarantee only enforced on read.
- #496 implements the design 8.1 catalog gate: authored table/column names are resolved
  against the live catalog at Initialize and replaced with the catalog's own spelling, so
  quoting becomes the backstop it was documented to be rather than the sole defence.
- #500 fixes the Runtime.Tests flake: four ordering/logic defects plus a class of tight
  presence budgets, now routed through a documented RuntimeActorTestBase.PresenceBudget.

Verified: full solution builds, all 41 unit-test projects green, Sql integration suite
21/21 against the real SQL Server on 10.100.0.35, and 30 consecutive Runtime.Tests runs
clean against a measured 13% baseline.

Still open by design: #499 (ClusterNode.DriverConfigOverridesJson is a third config
persistence surface DraftValidator cannot see) and #501 (two alarm-ack tests use
AwaitAssert for an absence assertion, so they prove nothing; the rewrite may
legitimately turn them red).
This commit is contained in:
Joseph Doherty
2026-07-25 22:27:26 -04:00
43 changed files with 2400 additions and 168 deletions
+4
View File
@@ -77,6 +77,10 @@
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" /> <PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
hard-coded uint constants against. Referenced by that TEST project only — the drivers
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio <!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
{ {
[Theory] [Theory]
[InlineData(192, 0x00000000u)] // Good [InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride [InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain [InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad [InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected [InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
{ {
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) }; var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
var snap = SampleMapper.ToAggregateSnapshot(a); var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value); Assert.Null(snap.Value);
} }
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts. // Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice). - `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind). - `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
- `ServerTimestampUtc`: `DateTime.UtcNow`. - `ServerTimestampUtc`: `DateTime.UtcNow`.
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too. - `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
**Step 4: run, expect PASS.** **Step 4: run, expect PASS.**
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` | | `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
| `CONDITION` | `String` (state word; §3.6) | | `CONDITION` | `String` (state word; §3.6) |
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node). **Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node).
### 3.4 `IReadable` — `/current` ### 3.4 `IReadable` — `/current`
@@ -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 `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 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. 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 - **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` 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), (`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier),
@@ -607,8 +641,15 @@ comment) and the env-overridable ConfigDb connection string:
file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes 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 drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so
this keeps the factory shape unchanged. this keeps the factory shape unchanged.
- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it - **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in
in display/logging and flags it dev-only. 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. - **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 / - Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
Kerberos) so no password transits config at all. Kerberos) so no password transits config at all.
@@ -41,9 +41,100 @@ public static class DraftValidator
ValidateUnsEffectiveLeafUniqueness(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors); ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors); ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
return errors; return errors;
} }
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
/// </summary>
/// <remarks>
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
/// deserialization. Nothing stopped the key being <b>written</b>. 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 <c>{"connectionString":"Server=…;Password=…"}</c> 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.</para>
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> 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.</para>
/// <para>The key is matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds
/// <c>ConnectionString</c> to a <c>connectionString</c> 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.</para>
/// <para><b>The message never echoes the value.</b> 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.</para>
/// </remarks>
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> 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));
}
}
/// <summary>
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> 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.
/// </summary>
/// <param name="json">The config blob to inspect.</param>
/// <param name="key">The property name to look for.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
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;
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver: /// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number"> /// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to /// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public static class AbCipStatusMapper public static class AbCipStatusMapper
{ {
public const uint Good = 0u; public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u; public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u; public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u; public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u; public const uint BadNotWritable = 0x803B0000u;
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary> /// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
/// <param name="status">The CIP general-status byte value.</param> /// <param name="status">The CIP general-status byte value.</param>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
public static class AbLegacyStatusMapper public static class AbLegacyStatusMapper
{ {
public const uint Good = 0u; public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u; public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u; public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u; public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u; public const uint BadNotWritable = 0x803B0000u;
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
/// <summary> /// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here /// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
/// <summary> /// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published /// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
{ {
rejectedTcs.TrySetResult(new DataValueSnapshot( rejectedTcs.TrySetResult(new DataValueSnapshot(
Value: null, Value: null,
StatusCode: 0x80000000u, // Bad StatusCode: StatusCodeMap.Bad,
SourceTimestampUtc: null, SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow)); ServerTimestampUtc: DateTime.UtcNow));
} }
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
{ {
tcs.TrySetResult(new DataValueSnapshot( tcs.TrySetResult(new DataValueSnapshot(
Value: null, Value: null,
StatusCode: 0x800B0000u, // BadTimeout StatusCode: StatusCodeMap.BadTimeout,
SourceTimestampUtc: null, SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow)); ServerTimestampUtc: DateTime.UtcNow));
} }
@@ -26,14 +26,23 @@ internal static class StatusCodeMap
{ {
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good), // OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte. // 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
//
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
// originally written as though the DA byte could be shifted into the substatus position, which
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
// against the pinned SDK's Opc.Ua.StatusCodes.
public const uint Good = 0x00000000u; public const uint Good = 0x00000000u;
public const uint GoodLocalOverride = 0x00D80000u; public const uint GoodLocalOverride = 0x00960000u;
public const uint Uncertain = 0x40000000u; public const uint Uncertain = 0x40000000u;
public const uint UncertainLastUsableValue = 0x40A40000u; public const uint UncertainLastUsableValue = 0x40900000u;
public const uint UncertainSensorNotAccurate = 0x408D0000u; public const uint UncertainSensorNotAccurate = 0x40930000u;
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u; public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
public const uint UncertainSubNormal = 0x408F0000u; public const uint UncertainSubNormal = 0x40950000u;
public const uint Bad = 0x80000000u; public const uint Bad = 0x80000000u;
public const uint BadConfigurationError = 0x80890000u; public const uint BadConfigurationError = 0x80890000u;
public const uint BadNotConnected = 0x808A0000u; public const uint BadNotConnected = 0x808A0000u;
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
public const uint BadWaitingForInitialData = 0x80320000u; public const uint BadWaitingForInitialData = 0x80320000u;
public const uint BadInternalError = 0x80020000u; public const uint BadInternalError = 0x80020000u;
/// <summary>
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
/// </summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary> /// <summary>
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort, /// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s /// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
public static uint Map(byte q) => q switch public static uint Map(byte q) => q switch
{ {
// Good family (192+) // Good family (192+)
192 => 0x00000000u, // Good 192 => HistorianStatusCodes.Good,
216 => 0x00D80000u, // Good_LocalOverride 216 => HistorianStatusCodes.GoodLocalOverride,
// Uncertain family (64-191) // Uncertain family (64-191)
64 => 0x40000000u, // Uncertain 64 => HistorianStatusCodes.Uncertain,
68 => 0x40900000u, // Uncertain_LastUsableValue 68 => HistorianStatusCodes.UncertainLastUsableValue,
80 => 0x40930000u, // Uncertain_SensorNotAccurate 80 => HistorianStatusCodes.UncertainSensorNotAccurate,
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded 84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
88 => 0x40950000u, // Uncertain_SubNormal 88 => HistorianStatusCodes.UncertainSubNormal,
// Bad family (0-63) // Bad family (0-63)
0 => 0x80000000u, // Bad 0 => HistorianStatusCodes.Bad,
4 => 0x80890000u, // Bad_ConfigurationError 4 => HistorianStatusCodes.BadConfigurationError,
8 => 0x808A0000u, // Bad_NotConnected 8 => HistorianStatusCodes.BadNotConnected,
12 => 0x808B0000u, // Bad_DeviceFailure 12 => HistorianStatusCodes.BadDeviceFailure,
16 => 0x808C0000u, // Bad_SensorFailure 16 => HistorianStatusCodes.BadSensorFailure,
20 => 0x80050000u, // Bad_CommunicationError 20 => HistorianStatusCodes.BadCommunicationError,
24 => 0x808D0000u, // Bad_OutOfService 24 => HistorianStatusCodes.BadOutOfService,
32 => 0x80320000u, // Bad_WaitingForInitialData 32 => HistorianStatusCodes.BadWaitingForInitialData,
// Unknown — fall back to category bucket so callers still get something usable. // Unknown — fall back to category bucket so callers still get something usable.
_ when q >= 192 => 0x00000000u, _ when q >= 192 => HistorianStatusCodes.Good,
_ when q >= 64 => 0x40000000u, _ when q >= 64 => HistorianStatusCodes.Uncertain,
_ => 0x80000000u, _ => HistorianStatusCodes.Bad,
}; };
} }
@@ -0,0 +1,75 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// <summary>
/// The OPC UA status codes this driver publishes, as named constants.
/// </summary>
/// <remarks>
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
/// A literal written at a call site is invisible to that guard, which is exactly how
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
/// </remarks>
internal static class HistorianStatusCodes
{
// ---- Good family ----
/// <summary>The value is good; no qualification.</summary>
public const uint Good = 0x00000000u;
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
public const uint GoodLocalOverride = 0x00960000u;
// ---- Uncertain family ----
/// <summary>The value is uncertain; no specific reason.</summary>
public const uint Uncertain = 0x40000000u;
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
public const uint UncertainLastUsableValue = 0x40900000u;
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
public const uint UncertainSensorNotAccurate = 0x40930000u;
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
public const uint UncertainSubNormal = 0x40950000u;
// ---- Bad family ----
/// <summary>The value is bad; no specific reason.</summary>
public const uint Bad = 0x80000000u;
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>The source is not connected (OPC DA quality 8).</summary>
public const uint BadNotConnected = 0x808A0000u;
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
public const uint BadDeviceFailure = 0x808B0000u;
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
public const uint BadSensorFailure = 0x808C0000u;
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>The source is out of service (OPC DA quality 24).</summary>
public const uint BadOutOfService = 0x808D0000u;
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
public const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>
/// The historian returned no data for the requested tag/interval. Distinct from a transport
/// failure: the query succeeded and the answer was empty.
/// </summary>
public const uint BadNoData = 0x809B0000u;
}
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// </summary> /// </summary>
internal static class SampleMapper internal static class SampleMapper
{ {
private const uint StatusGood = 0x00000000u; private const uint StatusGood = HistorianStatusCodes.Good;
private const uint StatusBadNoData = 0x800E0000u; private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary> /// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
private const byte GoodQualityFloor = 192; private const byte GoodQualityFloor = 192;
@@ -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 /// parameterized in SQL, so the few that must appear as text are emitted through
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field /// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para> /// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Not yet implemented:</b> design §8.1 also specifies that an authored table/column is /// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier /// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
/// rejects the tag. No such gate exists yet — an identifier reaches <see cref="QuoteIdentifier"/> /// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
/// straight from the authored <c>TagConfig</c> blob, so <b>quoting is currently the only defence</b>, /// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
/// not a backstop behind an upstream filter. Scrutinise it accordingly.</para> /// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
/// operator input. An identifier that resolves to nothing rejects its tag (which then publishes
/// <c>BadNodeIdUnknown</c>) rather than being quoted into a query against a nonexistent object.</para>
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse /// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
/// engine, so it is shared rather than duplicated. Implementations own their provider package; /// engine, so it is shared rather than duplicated. Implementations own their provider package;
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract /// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
@@ -89,6 +91,20 @@ public interface ISqlDialect
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary> /// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
string ListSchemasSql { get; } string ListSchemasSql { get; }
/// <summary>
/// Scalar query returning the schema an <b>unqualified</b> object name resolves to for the connecting
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
/// </summary>
/// <remarks>
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> 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.</para>
/// <para>It is a <em>query</em> rather than a name because the answer is per-connection, not per-dialect
/// — the same dialect resolves differently for two different logins.</para>
/// </remarks>
string DefaultSchemaSql { get; }
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary> /// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
string ListTablesSql { get; } string ListTablesSql { get; }
@@ -0,0 +1,137 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// 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.
/// <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
/// <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
/// <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
/// lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
/// identifier text in an emitted query is a string the database gave us.</para>
/// <para><b>Deliberately partial.</b> 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 <em>when we looked</em>", which is exactly the question the gate
/// asks.</para>
/// </summary>
public sealed class SqlCatalog
{
private readonly IReadOnlyList<string> _schemas;
/// <summary>Canonical schema → canonical table names in it.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
/// <summary>Constructs a catalog snapshot.</summary>
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
/// <param name="schemas">Every schema the connection can see.</param>
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
internal SqlCatalog(
string defaultSchema,
IReadOnlyList<string> schemas,
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
{
DefaultSchema = defaultSchema;
_schemas = schemas;
_tablesBySchema = tablesBySchema;
_columnsByTable = columnsByTable;
}
/// <summary>The schema an unqualified authored object name is resolved in.</summary>
public string DefaultSchema { get; }
/// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
/// <param name="authored">The authored schema, or null/blank for the default schema.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
public bool TryResolveSchema(string? authored, out string canonical)
{
if (string.IsNullOrWhiteSpace(authored))
{
canonical = DefaultSchema;
return true;
}
return TryMatch(_schemas, authored, out canonical);
}
/// <summary>Resolves an authored table/view name within a canonical schema.</summary>
/// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
/// <param name="authored">The authored table or view name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the relation exists in that schema.</returns>
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);
}
/// <summary>Resolves an authored column name within a canonical relation.</summary>
/// <param name="canonicalSchema">The resolved schema.</param>
/// <param name="canonicalTable">The resolved table or view.</param>
/// <param name="authored">The authored column name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the column exists on that relation.</returns>
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);
}
/// <summary>The dictionary key for a canonical relation.</summary>
/// <param name="schema">The canonical schema.</param>
/// <param name="table">The canonical table.</param>
/// <returns>The composite key.</returns>
internal static string QualifiedKey(string schema, string table) => schema + "." + table;
/// <summary>
/// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
/// <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
/// </summary>
/// <remarks>
/// <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
/// <c>num_value</c> and <c>NUM_VALUE</c> 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.</para>
/// <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
/// relation may legitimately carry both <c>Value</c> and <c>value</c>. 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.</para>
/// </remarks>
/// <param name="candidates">The catalog spellings to match against.</param>
/// <param name="authored">The authored name.</param>
/// <param name="canonical">The catalog's spelling, when matched.</param>
/// <returns><see langword="true"/> on an unambiguous match.</returns>
internal static bool TryMatch(IReadOnlyList<string> 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;
}
}
@@ -0,0 +1,241 @@
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// 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.
/// </summary>
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
/// <param name="Accepted">
/// The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
/// <see cref="SqlGroupPlanner"/>.
/// </param>
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
public sealed record SqlCatalogGateResult(
IReadOnlyList<SqlTagDefinition> Accepted,
IReadOnlyList<SqlCatalogRejection> Rejected);
/// <summary>
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
/// <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
/// </summary>
/// <remarks>
/// <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
/// sole defence: an identifier went from the authored <c>TagConfig</c> 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.</para>
/// <para><b>Charset first, catalog second.</b> Each identifier is passed through
/// <see cref="ISqlDialect.QuoteIdentifier"/> 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 <em>without its value being echoed</em>, 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.</para>
/// <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
/// driver's table, so its RawPath resolves to nothing and it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — 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.</para>
/// </remarks>
public static class SqlCatalogGate
{
/// <summary>
/// The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
/// four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
/// cannot see, so it cannot be allow-listed.
/// </summary>
private const int MaxNameParts = 2;
/// <summary>
/// Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
/// with catalog-spelled identifiers and the rejected ones with reasons.
/// </summary>
/// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
/// <param name="catalog">The catalog snapshot to validate against.</param>
/// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
/// <returns>The accepted and rejected sets.</returns>
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
public static SqlCatalogGateResult Apply(
IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(definitions);
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(dialect);
var accepted = new List<SqlTagDefinition>();
var rejected = new List<SqlCatalogRejection>();
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);
}
/// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
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<string, string?>(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;
}
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
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;
}
/// <summary>
/// True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
/// embed in SQL and to render into a log line or an AdminUI label.
/// </summary>
/// <remarks>
/// Uses <see cref="ISqlDialect.QuoteIdentifier"/> 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.
/// </remarks>
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
{
try
{
dialect.QuoteIdentifier(identifier);
return true;
}
catch (ArgumentException)
{
return false;
}
}
}
@@ -0,0 +1,189 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Loads the <see cref="SqlCatalog"/> slice the authored tags need, over one connection, using only the
/// dialect's catalog SQL (design §8.1).
/// </summary>
/// <remarks>
/// <para><b>Bounded by what is authored, not by what exists.</b> One query for the schema list, one for
/// the default schema, then one <see cref="ISqlDialect.ListTablesSql"/> per distinct authored schema and
/// one <see cref="ISqlDialect.ListColumnsSql"/> 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.</para>
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
/// <c>@schema</c>/<c>@table</c> 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.</para>
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> 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.</para>
/// </remarks>
internal static class SqlCatalogLoader
{
/// <summary>Column alias <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
private const string SchemaColumn = "TABLE_SCHEMA";
/// <summary>Column alias <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
private const string TableNameColumn = "TABLE_NAME";
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
/// </summary>
/// <param name="connection">An <b>already-open</b> connection. Not disposed here; the caller owns it.</param>
/// <param name="dialect">Supplies the catalog SQL.</param>
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
/// <param name="commandTimeout">Per-command server-side backstop.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The loaded catalog.</returns>
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
internal static async Task<SqlCatalog> LoadAsync(
DbConnection connection,
ISqlDialect dialect,
IReadOnlyCollection<string> 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<string, IReadOnlyList<string>>(StringComparer.Ordinal);
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(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);
}
/// <summary>
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
/// query answers null/blank.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static async Task<string> 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;
}
}
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
DbConnection connection,
string sql,
string columnName,
Action<DbCommand> bind,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var results = new List<string>();
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;
}
/// <summary>Binds one catalog-query parameter — the only way an authored name reaches the database here.</summary>
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);
}
}
@@ -74,7 +74,23 @@ public sealed class SqlDriver
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath = private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal); new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss.</summary> /// <summary>
/// The subset of <see cref="_tagsByRawPath"/> that survived the design §8.1 catalog gate, with every
/// identifier rewritten to the catalog's own spelling. <b>This — not the authored table — is what a
/// read or a poll resolves against</b>, so an identifier that never appeared in the catalog can never
/// reach a query.
/// <para><b>Why the two tables are separate.</b> A tag the gate rejected must still <em>exist</em> as a
/// node: §8.1's specified outcome is that it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, 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.</para>
/// <para>Swapped atomically, for the same reason the authored table is.</para>
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> _polledByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver; private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
/// <summary> /// <summary>
@@ -182,8 +198,18 @@ public sealed class SqlDriver
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary> /// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath); private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
/// <summary>The resolver's lookup: RawPath → authored definition, or null on a miss.</summary> /// <summary>
private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath); /// The catalog-validated snapshot. Read through a barrier — <see cref="ApplyCatalogGateAsync"/> swaps it.
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> PolledTags => Volatile.Read(ref _polledByRawPath);
/// <summary>
/// The resolver's lookup: RawPath → <b>catalog-validated</b> definition, or null on a miss.
/// <para>Deliberately reads <see cref="PolledTags"/> rather than <see cref="Tags"/>: a tag the gate
/// rejected must miss here so the reader publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, exactly as design §8.1 specifies.</para>
/// </summary>
private SqlTagDefinition? Lookup(string rawPath) => PolledTags.GetValueOrDefault(rawPath);
// ---- IDriver lifecycle ---- // ---- IDriver lifecycle ----
@@ -196,11 +222,16 @@ public sealed class SqlDriver
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> — /// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its /// <c>DriverInstanceActor</c> 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.</para> /// retry timer running, which is exactly the recovery a database that is merely down needs.</para>
/// <para>The liveness check is then followed by the design §8.1 <b>catalog gate</b>
/// (<see cref="ApplyCatalogGateAsync"/>), 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.</para>
/// </summary> /// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param> /// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param> /// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">The database could not be reached.</exception> /// <exception cref="InvalidOperationException">The database could not be reached, or its catalog could not be read.</exception>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
@@ -234,6 +265,36 @@ public sealed class SqlDriver
throw new InvalidOperationException(message, ex); 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)); WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running); TransitionHostTo(HostState.Running);
_logger.LogInformation( _logger.LogInformation(
@@ -594,6 +655,94 @@ public sealed class SqlDriver
} }
Volatile.Write(ref _tagsByRawPath, table); 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<string, SqlTagDefinition>(StringComparer.Ordinal));
}
// ---- catalog gate (design §8.1) ----
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// <para><b>Why this must run before the driver reports Healthy.</b> 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.</para>
/// <para><b>A dropped tag is not a driver fault.</b> It disappears from the table, so its RawPath
/// resolves to nothing and it publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — one operator
/// typo must not stop the other tags on that database, the same rule
/// <see cref="BuildTagTable"/> 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.</para>
/// <para><b>No tags ⇒ no I/O.</b> 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.</para>
/// <para>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.</para>
/// </remarks>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the tag table has been validated and republished.</returns>
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<string, SqlTagDefinition>(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);
}
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
private async Task<SqlCatalog> LoadCatalogAsync(
IReadOnlyCollection<string> 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 ---- // ---- liveness ----
@@ -611,18 +760,50 @@ public sealed class SqlDriver
/// </summary> /// </summary>
/// <param name="cancellationToken">The caller's token.</param> /// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the database has answered.</returns> /// <returns>A task that completes when the database has answered.</returns>
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);
/// <summary>
/// Runs <paramref name="work"/> under a hard wall-clock <paramref name="budget"/>, on the thread pool,
/// converting a breach into a <see cref="TimeoutException"/> worded from <paramref name="what"/>.
/// </summary>
/// <remarks>
/// <para>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 <em>inside</em> the provider's own cancellation handshake. Without an
/// independent wall clock, <c>DriverInstanceActor</c>'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.</para>
/// <para>Both the linked CTS deadline and an outer <see cref="TaskExtensions"/> wait are used, because
/// each covers a case the other does not: the CTS handles a provider that <em>does</em> honour
/// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of
/// its own connection and disposes it; <see cref="Detach"/> observes its eventual fault.</para>
/// </remarks>
/// <typeparam name="T">The work's result type.</typeparam>
/// <param name="work">The operation to bound. Receives the deadline-linked token.</param>
/// <param name="budget">The wall-clock allowance.</param>
/// <param name="what">Sentence fragment naming the operation, e.g. "the catalog queries did not return".</param>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>The work's result.</returns>
/// <exception cref="TimeoutException">The budget elapsed first.</exception>
private static async Task<T> RunBoundedAsync<T>(
Func<CancellationToken, Task<T>> work,
TimeSpan budget,
string what,
CancellationToken cancellationToken)
{ {
var budget = _options.CommandTimeout;
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task work; Task<T> running;
try try
{ {
deadline.CancelAfter(budget); deadline.CancelAfter(budget);
work = Task.Run( running = Task.Run(
async () => async () =>
{ {
try { await PingAsync(deadline.Token).ConfigureAwait(false); } try { return await work(deadline.Token).ConfigureAwait(false); }
finally { deadline.Dispose(); } finally { deadline.Dispose(); }
}, },
CancellationToken.None); CancellationToken.None);
@@ -636,20 +817,18 @@ public sealed class SqlDriver
try try
{ {
await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false); return await running.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
} }
catch (TimeoutException) catch (TimeoutException)
{ {
Detach(work); Detach(running);
throw new TimeoutException( throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
// Our deadline fired and the provider DID honour the linked token. // Our deadline fired and the provider DID honour the linked token.
Detach(work); Detach(running);
throw new TimeoutException( throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
} }
} }
@@ -752,6 +931,7 @@ public sealed class SqlDriver
{ {
await _poll.DisposeAsync().ConfigureAwait(false); await _poll.DisposeAsync().ConfigureAwait(false);
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal)); Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
} }
} }
@@ -44,6 +44,12 @@ public sealed class SqlServerDialect : ISqlDialect
public string ListSchemasSql => public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
/// <summary>
/// <c>SCHEMA_NAME()</c> 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.
/// </summary>
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
/// <inheritdoc/> /// <inheritdoc/>
public string ListTablesSql => public string ListTablesSql =>
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " + "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
@@ -70,9 +76,11 @@ public sealed class SqlServerDialect : ISqlDialect
/// control-character rule.</para> /// control-character rule.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted /// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para> /// input and this exception's text reaches the driver log.</para>
/// <para><b>This is currently the only defence, not a backstop.</b> Design §8.1 specifies that an /// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// identifier is validated against the live catalog before reaching here; that gate is not implemented /// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
/// yet, so an authored <c>TagConfig</c> table/column arrives unfiltered.</para> /// 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.</para>
/// </remarks> /// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param> /// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The bracket-quoted identifier.</returns> /// <returns>The bracket-quoted identifier.</returns>
@@ -19,9 +19,9 @@ public static class TwinCATStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
public const uint BadOutOfService = 0x80BE0000u; public const uint BadOutOfService = 0x808D0000u;
public const uint BadInvalidState = 0x80350000u; public const uint BadInvalidState = 0x80AF0000u;
// ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ---- // ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ----
@@ -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;
/// <summary>
/// The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
/// <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
/// <em>write</em> side, which is where a credential would actually land in the config database.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorSqlSecretTests
{
private const string Code = "SqlConnectionStringPersisted";
/// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
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");
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
/// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
/// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}
@@ -0,0 +1,160 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Guards every driver's hard-coded OPC UA status-code constant against
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
/// <em>name</em> disagrees with its <em>value</em> fails here.
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
/// Gitea #497 found in six places across four drivers.</para>
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
/// covered automatically, with no edit here.</para>
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
/// </summary>
public sealed class StatusCodeParityTests
{
/// <summary>
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
/// that does not start with one of them is not a status constant.
/// </summary>
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
/// <summary>
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
/// </summary>
private static readonly string[] NamePrefixesToStrip = ["Status"];
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
typeof(Opc.Ua.StatusCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
/// <summary>
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
/// </summary>
/// <remarks>
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
/// modifiers say nothing about whether a value reaches an OPC UA client.
/// </remarks>
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
{
var data = new TheoryData<string, string, string, string, uint>();
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
{
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
catch { continue; }
Type?[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
foreach (var type in types)
{
if (type is null) continue;
var fields = type.GetFields(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
foreach (var field in fields)
{
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
var sdkName = StripKnownPrefix(field.Name);
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
data.Add(
asm.GetName().Name!,
type.FullName ?? type.Name,
field.Name,
sdkName,
(uint)field.GetRawConstantValue()!);
}
}
}
return data;
}
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
private static string StripKnownPrefix(string fieldName)
{
foreach (var prefix in NamePrefixesToStrip)
{
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
return fieldName[prefix.Length..];
}
return fieldName;
}
/// <summary>
/// A driver's status constant must carry the value the SDK gives the code it is named after.
/// </summary>
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
/// <param name="type">The declaring type's full name (failure-message context).</param>
/// <param name="field">The constant's name as declared.</param>
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
/// <param name="declared">The value the driver declares.</param>
[Theory]
[MemberData(nameof(DeclaredStatusConstants))]
public void Driver_status_constant_matches_the_pinned_SDK(
string assembly, string type, string field, string sdkName, uint declared)
{
SdkStatusCodes.ShouldContainKey(sdkName,
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
"not start with Good/Uncertain/Bad.");
var expected = SdkStatusCodes[sdkName];
declared.ShouldBe(expected,
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
$"is 0x{expected:X8}" +
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
: ". The declared value is not any OPC UA status code."));
}
/// <summary>
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
/// </summary>
[Fact]
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
{
var found = DeclaredStatusConstants();
found.Count.ShouldBeGreaterThan(20,
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
"or the naming convention changed");
var assemblies = found
.Select(row => row.Data.Item1)
.Distinct(StringComparer.Ordinal)
.ToArray();
assemblies.Length.ShouldBeGreaterThan(3,
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
}
}
@@ -12,6 +12,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3"/> <PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/> <PackageReference Include="Shouldly"/>
<!-- StatusCodeParityTests only; supplies Opc.Ua.StatusCodes as the oracle. -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/> <PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio"> <PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -35,6 +37,10 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
puts it in StatusCodeParityTests' scope. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC UA status code.</param> /// <param name="expected">The expected OPC UA status code.</param>
[Theory] [Theory]
[InlineData((byte)192, 0x00000000u)] // Good [InlineData((byte)192, 0x00000000u)] // Good
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride [InlineData((byte)216, 0x00960000u)] // Good_LocalOverride
[InlineData((byte)64, 0x40000000u)] // Uncertain [InlineData((byte)64, 0x40000000u)] // Uncertain
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue [InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate [InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded [InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal [InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
[InlineData((byte)0, 0x80000000u)] // Bad [InlineData((byte)0, 0x80000000u)] // Bad
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError [InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected [InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
@@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC DA category byte.</param> /// <param name="expected">The expected OPC DA category byte.</param>
[Theory] [Theory]
[InlineData(0x00000000u, (byte)192)] // Good [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(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(0x80000000u, (byte)0)] // Bad
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category [InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category [InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
@@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests
{ {
[Theory] [Theory]
[InlineData(192, 0x00000000u)] // Good [InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride [InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain [InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad [InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected [InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -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 a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
var snap = SampleMapper.ToAggregateSnapshot(a); var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value); Assert.Null(snap.Value);
} }
@@ -475,6 +475,99 @@ public sealed class SqlServerReadTests
// ---- helpers ---- // ---- helpers ----
// ---- design §8.1 catalog gate (Gitea #496), against a real INFORMATION_SCHEMA ----
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Only a real SQL Server exercises the real <c>SELECT SCHEMA_NAME()</c> + <c>INFORMATION_SCHEMA</c>
/// 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.
/// </remarks>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary> /// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive() private void SkipUnlessLive()
{ {
@@ -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;
/// <summary>
/// The design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
/// catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
/// round-trips, not a hand-built <see cref="SqlCatalog"/>.
/// <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
/// rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
/// of rejecting every tag.</para>
/// </summary>
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);
}
/// <summary>
/// §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 <c>BadNodeIdUnknown</c>. Dropping the node instead would
/// turn a diagnosable Bad quality into a missing address-space entry.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
/// field and the reason.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
/// be a round-trip that can only fail.
/// </summary>
[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);
}
/// <summary>
/// <b>The fail-closed rule.</b> 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
/// <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
/// </summary>
[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<SqlDriver>.Instance);
var thrown = await Should.ThrowAsync<InvalidOperationException>(
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);
}
/// <summary>
/// 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.
/// </summary>
[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<SqlDriver>.Instance);
await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
// ---- helpers ----
/// <summary>
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
/// catalog-load failure modes can be driven against an otherwise-real dialect.
/// </summary>
/// <remarks>
/// A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
/// <c>new</c>-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.
/// </remarks>
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);
/// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
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);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The variables registered, in order.</summary>
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) { }
}
}
}
/// <summary>Records every log record, level + rendered message.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
}
@@ -0,0 +1,316 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// The design §8.1 catalog gate (Gitea #496), tested as a pure function over a hand-built
/// <see cref="SqlCatalog"/>. The end-to-end behaviour against a real catalog — and the proof that a
/// rejected tag keeps its node and publishes <c>BadNodeIdUnknown</c> — lives in
/// <see cref="SqlCatalogGateDriverTests"/>.
/// </summary>
public sealed class SqlCatalogGateTests
{
private static readonly SqliteDialect Dialect = new();
/// <summary>A catalog with one schema, two relations, and deliberately mixed-case column spellings.</summary>
private static SqlCatalog Catalog(string defaultSchema = "dbo") => new(
defaultSchema,
["dbo", "mes"],
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["dbo"] = ["TagValues", "LatestStatus"],
["mes"] = ["Orders"],
},
new Dictionary<string, IReadOnlyList<string>>(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);
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// Guessing <c>dbo</c> 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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 <em>without echoing it</em> — the
/// charset check runs before the catalog lookup precisely so no such string can reach a message.
/// </summary>
/// <remarks>
/// Driven against <see cref="SqlServerDialect"/>, not the test-only <see cref="SqliteDialect"/>: the
/// charset rules belong to the dialect (the gate delegates to
/// <see cref="ISqlDialect.QuoteIdentifier"/> 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.
/// </remarks>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// On a case-sensitive collation a relation may legitimately carry both <c>Value</c> and <c>value</c>.
/// 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.
/// </summary>
[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<string, IReadOnlyList<string>>(StringComparer.Ordinal) { ["dbo"] = ["T"] },
new Dictionary<string, IReadOnlyList<string>>(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");
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>Every identifier-bearing field is checked, not just the two the key-value model happens to use.</summary>
[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
}
/// <summary>An absent optional identifier is not a rejection — only a present, unresolvable one is.</summary>
[Fact]
public void An_absent_optional_timestamp_column_is_left_alone()
{
var accepted = Apply(KeyValueTag(timestampColumn: null)).Accepted.ShouldHaveSingleItem();
accepted.TimestampColumn.ShouldBeNull();
}
}
@@ -12,16 +12,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <em>identifier</em> — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes /// <em>identifier</em> — 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 /// 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. /// cannot alter the database; the seed table is intact after every hostile poll.
/// <para><b>Scope note — what this suite does NOT assume.</b> Design §8.1 also specifies a catalog gate: /// <para><b>Scope note — this suite deliberately tests the layer BELOW the catalog gate.</b> Design
/// validate every authored table/column against <c>INFORMATION_SCHEMA</c> before quoting it, so an unknown /// §8.1's catalog gate now exists (Gitea #496): <see cref="SqlCatalogGate"/> validates every authored
/// identifier <em>rejects the tag</em>. <b>No such gate exists in the driver yet</b> (see the "Not yet /// table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier
/// implemented" note on <see cref="ISqlDialect"/>), and no task in this workstream builds one. So a hostile /// is rejected up front and its tag reads <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — proven by
/// identifier here is <b>not</b> rejected as <c>BadNodeIdUnknown</c> — it is bracket-/double-quoted into a /// <c>SqlCatalogGateDriverTests</c>.</para>
/// single nonexistent identifier, the query then fails after the connection opened, and the tag Bad-codes /// <para>The tests here construct a <see cref="SqlPollReader"/> <b>directly</b>, with hand-built
/// as a query failure (<see cref="SqlStatusCodes.BadCommunicationError"/>). This suite proves the payload /// definitions that never pass through the gate, and that is the point: defence in depth is only worth
/// is <em>inert</em> — it does not execute and the table survives — which is the guarantee the code /// the name if each layer holds on its own. These assertions pin the <em>quoting backstop</em> — that
/// actually makes. The catalog gate is a separate follow-up; a test that pretended it existed would be /// even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a
/// asserting fiction.</para> /// nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure
/// (<see cref="SqlStatusCodes.BadCommunicationError"/>), and the seed table survives. Rewriting them to
/// expect <c>BadNodeIdUnknown</c> would delete the only coverage the backstop has and leave the gate as a
/// single point of failure.</para>
/// </summary> /// </summary>
public sealed class SqlInjectionRegressionTests public sealed class SqlInjectionRegressionTests
{ {
@@ -68,9 +71,11 @@ public sealed class SqlInjectionRegressionTests
using var fixture = new SqlitePollFixture(); using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
// A table name carrying a statement terminator + DROP. There is NO catalog gate, so this is not // A table name carrying a statement terminator + DROP. The reader is driven directly, so the
// rejected up front — it is quoted into one nonexistent identifier. The query fails (no such table), // catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one
// the connection having opened, so the tag Bad-codes as a query failure. The DROP never runs. // 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, var reader = NewReader(fixture,
new SqlTagDefinition( new SqlTagDefinition(
Name: "Evil", Name: "Evil",
@@ -83,7 +88,7 @@ public sealed class SqlInjectionRegressionTests
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); 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(); SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
// The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched. // The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched.
@@ -64,6 +64,12 @@ public sealed class SqliteDialect : ISqlDialect
/// </summary> /// </summary>
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA"; public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
/// <summary>
/// SQLite has no per-principal default schema, so an unqualified name always resolves in
/// <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
/// </summary>
public string DefaultSchemaSql => $"SELECT '{MainSchema}'";
/// <summary> /// <summary>
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the /// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the /// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
@@ -55,11 +55,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. // 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<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() => AwaitAssert(() =>
{ {
recorder.Writes.Count.ShouldBe(1); recorder.Writes.Count.ShouldBe(1);
@@ -79,10 +77,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe(); RouteWriteUntilAccepted(actor, $"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() => AwaitAssert(() =>
{ {
recorder.Writes.Count.ShouldBe(1); recorder.Writes.Count.ShouldBe(1);
@@ -91,6 +87,48 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
}, duration: Timeout); }, duration: Timeout);
} }
/// <summary>
/// Routes a write, retrying until the host accepts it, and fails with the host's own rejection
/// <c>Reason</c> if it never does.
/// </summary>
/// <remarks>
/// <para><b>Why a retry rather than a single Tell.</b> <c>ApplyAck</c> — which
/// <see cref="SpawnHostAndApply"/> waits for — marks the end of the <em>apply</em>, not the point at
/// which a write can succeed. Several things still have to happen after it: the spawned child has to
/// finish <c>InitializeAsync</c> and leave <c>Connecting</c> (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.</para>
/// <para><b>Why retrying does not weaken the assertion.</b> Every rejection branch — the Primary
/// gate, an unresolved reverse map, "driver not running", and the child's own pre-Connected
/// fast-fail — replies <em>without</em> reaching the driver. A rejected attempt records nothing, so
/// the caller's <c>Writes.Count.ShouldBe(1)</c> still means exactly what it did before: the accepted
/// write reached the driver exactly once.</para>
/// <para>The failure message carries the last <c>Reason</c> so a genuine regression is diagnosable
/// rather than surfacing as a bare "expected True".</para>
/// </remarks>
/// <param name="actor">The driver-host actor.</param>
/// <param name="nodeId">The ns-qualified NodeId string, as the node manager passes it.</param>
/// <param name="value">The value to write.</param>
/// <param name="realm">The address-space realm the NodeId belongs to.</param>
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<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(1));
lastReason = result.Reason ?? "(none)";
result.Success.ShouldBeTrue($"host kept rejecting the write; last reason: {lastReason}");
},
duration: Timeout,
interval: TimeSpan.FromMilliseconds(50));
}
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver /// <summary>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.</summary> /// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
[Fact] [Fact]
@@ -88,7 +88,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task Write_propagates_status_code_on_Bad_result() 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 driver = new WritableStubDriver { NextStatusCode = badStatus };
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
@@ -11,6 +11,25 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
/// </summary> /// </summary>
public abstract class RuntimeActorTestBase : TestKit public abstract class RuntimeActorTestBase : TestKit
{ {
/// <summary>
/// Shared upper bound for a <b>presence</b> wait (<c>AwaitAssert</c> / <c>AwaitCondition</c>).
/// </summary>
/// <remarks>
/// <para><b>A budget, not a delay.</b> 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
/// <em>cannot</em> make a genuinely failing assertion pass — it only changes how quickly a real
/// breakage is reported. That is the opposite of an <b>absence</b> window (<c>ExpectNoMsg</c>,
/// 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.</para>
/// <para><b>Why it exists (Gitea #500).</b> This assembly runs 44 Akka test classes roughly 14-way
/// parallel, each with its own <see cref="TestKit"/> ActorSystem. Budgets of 300500 ms sized on an
/// idle machine are comfortable in isolation and marginal under that contention: three consecutive
/// 30-run verification rounds each surfaced a <em>different</em> test failing on one, in three
/// different files. Fixing them one at a time was chasing a distribution rather than a defect.</para>
/// </remarks>
protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
/// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary> /// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary>
protected static string AkkaTestHocon => @" protected static string AkkaTestHocon => @"
akka { akka {
@@ -57,7 +57,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
State(Adm, RedundancyRole.Detached))); State(Adm, RedundancyRole.Detached)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies the child for a departed peer is stopped when the next snapshot omits it.</summary> /// <summary>Verifies the child for a departed peer is stopped when the next snapshot omits it.</summary>
@@ -71,11 +71,11 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies a single-node snapshot (just the local node) spawns no children.</summary> /// <summary>Verifies a single-node snapshot (just the local node) spawns no children.</summary>
@@ -88,7 +88,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies a previously-removed peer is respawned when it re-appears, without an /// <summary>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(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
sup.Tell(Snapshot( sup.Tell(Snapshot(
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Locks in the stale-Terminated guard: when an OLD (already-replaced) child's /// <summary>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(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
AwaitAssert(() => spawned.Count.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => spawned.Count.ShouldBe(1), duration: PresenceBudget);
var oldRef = spawned[0]; var oldRef = spawned[0];
// Drop the peer -> child #0 stopped, ChildCount back to 0. // Drop the peer -> child #0 stopped, ChildCount back to 0.
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), 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. // Re-add the SAME peer -> a NEW child #1 (the FRESH ref) is spawned.
sup.Tell(Snapshot( sup.Tell(Snapshot(
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
AwaitAssert(() => spawned.Count.ShouldBe(2), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => spawned.Count.ShouldBe(2), duration: PresenceBudget);
// Now deliver a STALE Terminated for the OLD ref. The current child for Peer is the fresh // 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. // 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)); sup.Tell(new Terminated(oldRef, existenceConfirmed: true, addressTerminated: false));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
} }
@@ -295,10 +295,24 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
// The first drain returns false (entry retained); after the backoff the retry drain succeeds // The first drain returns false (entry retained); after the backoff the retry drain succeeds
// and acks, truncating the outbox to 0. // and acks, truncating the outbox to 0.
await AwaitAssertAsync(async () => //
Assert.Equal(0, await outbox.CountAsync(default)), TimeSpan.FromSeconds(5)); // 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
Assert.True(writer.CallCount >= 2, "the writer must have been called at least twice (a retry happened)"); // 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] [Fact]
@@ -23,9 +23,27 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
/// <summary>The local node id the gating tests construct the adapter with.</summary> /// <summary>The local node id the gating tests construct the adapter with.</summary>
private static readonly NodeId LocalNode = new("node-A"); private static readonly NodeId LocalNode = new("node-A");
/// <summary>A short window we allow the fire-and-forget enqueue to land within.</summary> /// <summary>
/// The window an <b>absence</b> assertion waits before concluding nothing arrived
/// (<c>ExpectNoMsg</c>). Its length is a calibration decision — long enough that a message which
/// was going to arrive would have — so it is deliberately NOT generous.
/// </summary>
private static readonly TimeSpan Settle = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan Settle = TimeSpan.FromMilliseconds(500);
/// <summary>
/// The budget a <b>presence</b> assertion may take to become true (<c>AwaitAssert</c>).
/// </summary>
/// <remarks>
/// Separate from <see cref="Settle"/> 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 <c>AwaitAssert</c> 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 <c>ExpectNoMsg</c> in
/// the class, so they kept a 500 ms budget that a fully parallel assembly run occasionally missed.
/// </remarks>
private static readonly TimeSpan AssertTimeout = TimeSpan.FromSeconds(5);
/// <summary>Thread-safe fake sink that records every <see cref="EnqueueAsync"/> call.</summary> /// <summary>Thread-safe fake sink that records every <see cref="EnqueueAsync"/> call.</summary>
private sealed class RecordingSink : IAlarmHistorianSink private sealed class RecordingSink : IAlarmHistorianSink
{ {
@@ -101,7 +119,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
actor.Tell(SampleEvent()); actor.Tell(SampleEvent());
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Secondary suppression (T2): when the cached local role is Secondary, the adapter MUST /// <summary>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); TellRedundancyRole(actor, RedundancyRole.Primary);
actor.Tell(SampleEvent()); actor.Tell(SampleEvent());
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Absent-node default-historize (T5): a snapshot that mentions only a DIFFERENT node /// <summary>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()); actor.Tell(SampleEvent());
// Local role is still unknown ⇒ default-historize path: sink must record exactly one enqueue. // 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);
} }
/// <summary>Builds an <see cref="AlarmTransitionEvent"/> (the shape published on the <c>alerts</c> /// <summary>Builds an <see cref="AlarmTransitionEvent"/> (the shape published on the <c>alerts</c>
@@ -222,7 +240,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
e.Severity.ShouldBe(AlarmSeverity.High); e.Severity.ShouldBe(AlarmSeverity.High);
e.Comment.ShouldBe("note"); e.Comment.ShouldBe("note");
}, },
Settle); AssertTimeout);
} }
/// <summary>Secondary suppression for alerts (T7): a Secondary node must NOT historize a transition /// <summary>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); TellRedundancyRole(actor, RedundancyRole.Primary);
actor.Tell(SampleTransition()); actor.Tell(SampleTransition());
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Per-alarm opt-out (T8b): a Primary node must NOT historize a transition whose /// <summary>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); TellRedundancyRole(actor, RedundancyRole.Primary);
actor.Tell(SampleTransition(historizeToAveva: null)); actor.Tell(SampleTransition(historizeToAveva: null));
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Severity buckets (T9): the OPC UA 11000 numeric severity on the transition maps onto /// <summary>Severity buckets (T9): the OPC UA 11000 numeric severity on the transition maps onto
@@ -308,7 +326,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
AwaitAssert( AwaitAssert(
() => sink.Events.ShouldHaveSingleItem().Severity.ShouldBe(expected), () => sink.Events.ShouldHaveSingleItem().Severity.ShouldBe(expected),
Settle); AssertTimeout);
} }
/// <summary>Rolling-restart null default (T10): an old-format transition deserialized by Akka's JSON /// <summary>Rolling-restart null default (T10): an old-format transition deserialized by Akka's JSON
@@ -326,6 +344,6 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
AwaitAssert( AwaitAssert(
() => sink.Events.ShouldHaveSingleItem().AlarmTypeName.ShouldBe("AlarmCondition"), () => sink.Events.ShouldHaveSingleItem().AlarmTypeName.ShouldBe("AlarmCondition"),
Settle); AssertTimeout);
} }
} }
@@ -48,11 +48,16 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed")); var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed"));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); 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(() => AwaitAssert(() =>
{ {
recorder.Total.ShouldBeGreaterThanOrEqualTo(1); recorder.Total.ShouldBeGreaterThanOrEqualTo(1);
recorder.WithTag("kind", "rebuild").ShouldBeGreaterThanOrEqualTo(1); recorder.WithTag("kind", "rebuild").ShouldBeGreaterThanOrEqualTo(1);
}, duration: TimeSpan.FromSeconds(2)); }, duration: TimeSpan.FromSeconds(15));
} }
/// <summary>A clean rebuild does NOT increment the apply-failed meter (Info-only happy path).</summary> /// <summary>A clean rebuild does NOT increment the apply-failed meter (Info-only happy path).</summary>
@@ -45,7 +45,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
sink.Calls.ShouldContain("EF:eq-1"); sink.Calls.ShouldContain("EF:eq-1");
sink.Calls.ShouldContain("EF:eq-2"); sink.Calls.ShouldContain("EF:eq-2");
sink.Calls.ShouldContain("NA:line-1"); sink.Calls.ShouldContain("NA:line-1");
}, duration: TimeSpan.FromSeconds(2)); }, duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
} }
@@ -85,7 +85,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
sink: sink, dbFactory: db, applier: applier)); sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); 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 sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild
var callsAfterFirst = sink.Calls.Count; var callsAfterFirst = sink.Calls.Count;
@@ -107,7 +107,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
} }
/// <summary> /// <summary>
@@ -132,7 +132,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact));
// The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised… // 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. // …and the address-space-wiping raw-sink fallback was NOT taken.
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
} }
@@ -176,7 +176,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
} }
/// <summary> /// <summary>
@@ -197,7 +197,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep))); 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); 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. // 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". // 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); sinkA.RebuildCalls.ShouldBe(0);
// t-main (MAIN cluster) must NOT leak onto the SITE-A node. // t-main (MAIN cluster) must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1"); sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1");
@@ -249,7 +249,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); 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.RebuildCalls.ShouldBe(0);
sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1"); 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)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); 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); sink.RebuildCalls.ShouldBe(0);
var calls = sink.Calls.ToList(); 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)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); 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); sink.RebuildCalls.ShouldBe(0);
var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:")); 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")); var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2"));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); 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. // No new NodeAdded announcement was raised for the rebuild-kind deploy.
sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst); 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)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); 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; var callsAfterFirst = sink.Calls.Count;
// The next deploy arrives while the ConfigDb is briefly unreachable. // 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)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); 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. // 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")); var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); 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);
} }
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> can be made to /// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> 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 // 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. // (area + line + equipment all added), so NO full rebuild; the folder is materialised directly.
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); 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); sink.RebuildCalls.ShouldBe(0);
// Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and // 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(() => AwaitAssert(() =>
{ {
sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South")); 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 sink.RebuildCalls.ShouldBe(0); // the rename did NOT force a full rebuild
} }
@@ -53,7 +53,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
sink.Values[0].Value.ShouldBe(3.14); sink.Values[0].Value.ShouldBe(3.14);
sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good); sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good);
sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain); sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain);
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.</summary> /// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.</summary>
@@ -73,7 +73,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
sink.Alarms[0].State.Active.ShouldBeTrue(); sink.Alarms[0].State.Active.ShouldBeTrue();
sink.Alarms[0].State.Acknowledged.ShouldBeFalse(); sink.Alarms[0].State.Acknowledged.ShouldBeFalse();
sink.Alarms[0].State.Severity.ShouldBe((ushort)700); sink.Alarms[0].State.Severity.ShouldBe((ushort)700);
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary> /// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary>
@@ -93,7 +93,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi"); q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
q.Quality.ShouldBe(OpcUaQuality.Bad); q.Quality.ShouldBe(OpcUaQuality.Bad);
q.Realm.ShouldBe(AddressSpaceRealm.Raw); q.Realm.ShouldBe(AddressSpaceRealm.Raw);
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test /// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
@@ -117,7 +117,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
} }
/// <summary>Verifies that <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> forwards to the /// <summary>Verifies that <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> forwards to the
@@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes")); sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes"));
sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false)); sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false));
sink.ModelChanges.ShouldContain("EQ-1"); sink.ModelChanges.ShouldContain("EQ-1");
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary> /// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
@@ -161,7 +161,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the very first computed ServiceLevel is always published even when it is /// <summary>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())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader.</summary> /// <summary>Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader.</summary>
@@ -210,7 +210,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(snapshot); actor.Tell(snapshot);
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that RedundancyStateChanged for secondary publishes 100.</summary> /// <summary>Verifies that RedundancyStateChanged for secondary publishes 100.</summary>
@@ -231,7 +231,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(snapshot); actor.Tell(snapshot);
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 250 for a healthy primary role-leader /// <summary>Verifies that the calculator path computes 250 for a healthy primary role-leader
@@ -256,7 +256,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 240 for a healthy non-leader secondary /// <summary>Verifies that the calculator path computes 240 for a healthy non-leader secondary
@@ -282,7 +282,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 100 when the DB is unreachable /// <summary>Verifies that the calculator path computes 100 when the DB is unreachable
@@ -307,7 +307,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)100), AwaitAssert(() => publisher.Levels.ShouldContain((byte)100),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 200 for a stale snapshot when the DB is /// <summary>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())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that a detached local node publishes 0 (the calculator does not model /// <summary>Verifies that a detached local node publishes 0 (the calculator does not model
@@ -362,7 +362,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
}, },
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
// Now detach — expect the guard to drive ServiceLevel down to 0. // Now detach — expect the guard to drive ServiceLevel down to 0.
actor.Tell(new RedundancyStateChanged( actor.Tell(new RedundancyStateChanged(
@@ -374,7 +374,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that an actively-observed, recent peer probe of MY endpoint that came back /// <summary>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())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies branch (3) of <c>OpcUaProbeOk()</c>: a peer's NEGATIVE verdict about this node /// <summary>Verifies branch (3) of <c>OpcUaProbeOk()</c>: 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. // Healthy (250), NOT 0 — proves an aged negative verdict does not demote.
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that with no peer probe result ever received, <c>OpcUaProbeOk()</c> defaults /// <summary>Verifies that with no peer probe result ever received, <c>OpcUaProbeOk()</c> defaults
@@ -474,7 +474,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that a later <c>Ok==true</c> peer probe supersedes an earlier <c>Ok==false</c> /// <summary>Verifies that a later <c>Ok==true</c> peer probe supersedes an earlier <c>Ok==false</c>
@@ -503,7 +503,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that a peer probe result about a DIFFERENT node is ignored — it does not /// <summary>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())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies the legacy back-compat seam: with no DB-health probe wired, the handler /// <summary>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())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the periodic <c>HealthTick</c> Asks the local <see cref="DbHealthProbeActor"/> /// <summary>Verifies that the periodic <c>HealthTick</c> Asks the local <see cref="DbHealthProbeActor"/>
@@ -607,7 +607,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary> /// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary>
@@ -629,7 +629,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness /// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness
@@ -654,7 +654,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less /// <summary>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())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
actor.Tell(new RedundancyStateChanged( actor.Tell(new RedundancyStateChanged(
Nodes: new[] Nodes: new[]
@@ -688,7 +688,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default /// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default
@@ -714,7 +714,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/> /// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/>
@@ -28,7 +28,23 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
/// </summary> /// </summary>
public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
{ {
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8); /// <summary>
/// Upper bound for the <b>presence</b> waits in this class (<c>ExpectMsg</c> / <c>FishForMessage</c>).
/// </summary>
/// <remarks>
/// <para>Sized for a <b>real Roslyn compilation</b>, not for message passing.
/// <c>ApplyScriptedAlarms</c> drives <c>ScriptedAlarmEngine.LoadAsync</c>, which compiles every
/// alarm predicate through <c>ScriptEvaluator.Compile</c>; the <c>RegisterInterest</c> 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).</para>
/// <para>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 <b>absence</b> assertions in this class deliberately keep their own
/// short literals — there the elapsed time is the point.</para>
/// </remarks>
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
/// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary> /// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary>
private static EquipmentScriptedAlarmPlan Plan( private static EquipmentScriptedAlarmPlan Plan(
@@ -79,7 +79,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
entry.Message.ShouldContain("syntax error"); entry.Message.ShouldContain("syntax error");
entry.ScriptId.ShouldBe("script-7"); entry.ScriptId.ShouldBe("script-7");
entry.VirtualTagId.ShouldBe("vt-1"); 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 // 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 // inputs-ready gate is vacuously satisfied, so a Bad EvaluationResult reaches the parent (this
@@ -260,7 +260,15 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
// The old child is stopped (PostStop ⇒ UnregisterInterest) and a new one spawned // 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 // (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. // 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; DependencyMuxActor.RegisterInterest? reg2 = null;
IActorRef? secondChild = null;
var sawUnregister = false; var sawUnregister = false;
for (var i = 0; i < 2; i++) for (var i = 0; i < 2; i++)
{ {
@@ -269,6 +277,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
{ {
case DependencyMuxActor.RegisterInterest r: case DependencyMuxActor.RegisterInterest r:
reg2 = r; reg2 = r;
secondChild = mux.LastSender; // sender OF THIS message, not of the last one seen
break; break;
case DependencyMuxActor.UnregisterInterest: case DependencyMuxActor.UnregisterInterest:
sawUnregister = true; sawUnregister = true;
@@ -282,7 +291,8 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
reg2.TagRefs.ShouldNotContain("A"); reg2.TagRefs.ShouldNotContain("A");
// The replacement is a different actor ref than the original (auto-named, so no collision). // The replacement is a different actor ref than the original (auto-named, so no collision).
mux.LastSender.ShouldNotBe(firstChild); secondChild.ShouldNotBeNull();
secondChild.ShouldNotBe(firstChild);
} }
/// <summary> /// <summary>