fix(code-review): resolve Batch 2 open findings (AbCip, AbLegacy, Galaxy, FOCAS)

- Driver.AbCip.Contracts-001: parse 'writable' from TagConfig JSON (default true) instead of hardcoding
- Driver.AbCip.Contracts-002/-003: Dt type comment; drop dead [Display]/[Range] annotations
- Driver.AbCip.Contracts-004: dedicated AbCipEquipmentTagParser test class (+15)
- Driver.AbCip-017: document Tick severity Low-fallback on Bad severity read
- Driver.AbLegacy.Contracts-002/-003/-004: isArray-scalar remarks (+tests), MaxTagBytes/ForFamily docs
- Driver.Galaxy.Browser-003 + Driver.Galaxy.Contracts-003: extract ResolveApiKey -> GalaxySecretRef (dedup)
- Driver.Galaxy-019: cache buffered-interval only on Ok + ILogger warnings + ClassifyIntervalReply (+tests)
- Driver.FOCAS.Contracts-002: thread WriteIdempotent through DiscoverAsync (+test)
This commit is contained in:
Joseph Doherty
2026-06-20 22:43:36 -04:00
parent 3cc6a5f30d
commit ab57e53b92
26 changed files with 577 additions and 220 deletions
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 | | Review date | 2026-06-19 |
| Commit reviewed | `a19b0f86` | | Commit reviewed | `a19b0f86` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 4 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -44,7 +44,7 @@ a category produced nothing rather than leaving it blank.
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs:42` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs:42` |
| Status | Open | | Status | Resolved |
**Description:** `AbCipEquipmentTagParser.TryParse` hard-codes `Writable: true` on every **Description:** `AbCipEquipmentTagParser.TryParse` hard-codes `Writable: true` on every
equipment-tag definition it produces, regardless of any `writable` field in the TagConfig JSON. equipment-tag definition it produces, regardless of any `writable` field in the TagConfig JSON.
@@ -70,7 +70,14 @@ record's `Writable` parameter. (2) Either return `false` from `TryParse` when `d
to `AbCipDataType.Structure` (equipment-tag flow cannot declare members), or add an explicit to `AbCipDataType.Structure` (equipment-tag flow cannot declare members), or add an explicit
comment documenting the black-box dotted-path behaviour so the next reader understands the intent. comment documenting the black-box dotted-path behaviour so the next reader understands the intent.
**Resolution:** _(empty until closed)_ **Resolution:** Resolved 2026-06-20 — (1) `TryParse` now reads the optional `"writable"` boolean
field from the TagConfig JSON and threads it into `AbCipTagDefinition.Writable`, defaulting to
`true` when the field is absent. The `<remarks>` on `TryParse` was updated to document this
behaviour. (2) For the Structure concern, zero-behaviour-change option taken: an inline comment
was added at the `dataType` parse site in `TryParse` documenting that a `Structure` dataType on an
equipment tag is treated as a black-box dotted-path read (libplctag resolves the full path; the
equipment-tag flow does not enumerate UDT members). New tests in `AbCipEquipmentTagParserTests`
cover `writable:false`, `writable` absent, and the `Structure` path. Suite green (322 tests).
--- ---
@@ -81,7 +88,7 @@ comment documenting the black-box dotted-path behaviour so the next reader under
| Severity | Low | | Severity | Low |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDataType.cs:28` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDataType.cs:28` |
| Status | Open | | Status | Resolved |
**Description:** The inline comment on `AbCipDataType.Dt` reads: **Description:** The inline comment on `AbCipDataType.Dt` reads:
@@ -114,7 +121,9 @@ Dt, // Logix DATE (0xCD — 4-byte unsigned days since 1984-01-01) or DATE_A
No behaviour change; documentation only. No behaviour change; documentation only.
**Resolution:** _(empty until closed)_ **Resolution:** Resolved 2026-06-20 — replaced the inaccurate single-line comment on `Dt` with a
3-line comment describing both mapped CIP types (`0xCD` DATE / `0xCF` DT), the 4-byte read stride,
and the truncation note for DATE_AND_TIME. Build green (0 errors, 0 warnings).
--- ---
@@ -125,7 +134,7 @@ No behaviour change; documentation only.
| Severity | Low | | Severity | Low |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs:84-85` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs:84-85` |
| Status | Open | | Status | Resolved |
**Description:** `AbCipDriverOptions.ProbeTimeoutSeconds` carries `[Display]` and `[Range(1, 60)]` **Description:** `AbCipDriverOptions.ProbeTimeoutSeconds` carries `[Display]` and `[Range(1, 60)]`
attributes from `System.ComponentModel.DataAnnotations`. No other driver contracts project attributes from `System.ComponentModel.DataAnnotations`. No other driver contracts project
@@ -144,7 +153,11 @@ If the intent is to document the valid range, an `<remarks>` tag (e.g. "Valid ra
seconds; the AdminUI clamps to 60s server-side.") achieves the same goal without the attribute seconds; the AdminUI clamps to 60s server-side.") achieves the same goal without the attribute
dependency. dependency.
**Resolution:** _(empty until closed)_ **Resolution:** Resolved 2026-06-20 — removed `[Display]` and `[Range(1, 60)]` from
`ProbeTimeoutSeconds` and removed the `using System.ComponentModel.DataAnnotations;` directive
(confirmed it was used only by those two attributes). Added a `<remarks>` element reading
"Valid range: 160 seconds; the AdminUI clamps to 60s server-side." to preserve the intent
in documentation. Build green (0 errors, 0 warnings).
--- ---
@@ -155,7 +168,7 @@ dependency.
| Severity | Low | | Severity | Low |
| Category | Testing coverage | | Category | Testing coverage |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs` (entire file) | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs` (entire file) |
| Status | Open | | Status | Resolved |
**Description:** The contracts module has no dedicated test project, and `AbCipEquipmentTagParser.TryParse` **Description:** The contracts module has no dedicated test project, and `AbCipEquipmentTagParser.TryParse`
is the module's only non-trivial logic. Its `ReadArrayShape` helper has four distinct outcome is the module's only non-trivial logic. Its `ReadArrayShape` helper has four distinct outcome
@@ -175,7 +188,14 @@ is needed. Cover: valid scalar round-trip, 1-element array, N-element array, eac
array-shape combination, non-JSON and non-object input, missing/blank `tagPath`, the Structure array-shape combination, non-JSON and non-object input, missing/blank `tagPath`, the Structure
DataType path, and the `Writable` default. DataType path, and the `Writable` default.
**Resolution:** _(empty until closed)_ **Resolution:** Resolved 2026-06-20 — added `AbCipEquipmentTagParserTests.cs` to
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` (15 tests). Covers: valid scalar
round-trip; 1-element array (`isArray:true, arrayLength:1`); N-element array; `isArray:true +
arrayLength:0` → scalar; `isArray:true + arrayLength absent` → scalar; non-JSON input → false;
non-object JSON array → false; non-object JSON string → false; missing `tagPath` → false; blank
`tagPath` → false; `tagPath` as number → false; `writable:false` honoured; `writable` absent
defaults to `true`; `writable:true` explicit; `dataType:"Structure"` accepted with `Members:null`
(documents current black-box behaviour). Suite green (322 tests).
--- ---
+8 -2
View File
@@ -7,7 +7,7 @@
| Review date | 2026-06-19 | | Review date | 2026-06-19 |
| Commit reviewed | `7286d320` | | Commit reviewed | `7286d320` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 1 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -337,7 +337,7 @@ flag and the legacy `ElementCount > 1` paths opt out). Full suite green (303 tes
| Severity | Low | | Severity | Low |
| Category | Documentation & comments | | Category | Documentation & comments |
| Location | `AbCipAlarmProjection.cs:173-185` (`Tick`) | | Location | `AbCipAlarmProjection.cs:173-185` (`Tick`) |
| Status | Open | | Status | Resolved |
**Description:** `AbCipAlarmProjection.Tick` gates each node on the `InFaulted` snapshot's **Description:** `AbCipAlarmProjection.Tick` gates each node on the `InFaulted` snapshot's
`StatusCode` (`if (inFaultedDv.StatusCode != Good) continue;`) but reads the `Severity` `StatusCode` (`if (inFaultedDv.StatusCode != Good) continue;`) but reads the `Severity`
@@ -357,6 +357,12 @@ severity read is Bad, or add an XML/inline comment on `Tick` stating that severi
decision (what severity to surface when it is genuinely unknown) rather than a mechanical fix, decision (what severity to surface when it is genuinely unknown) rather than a mechanical fix,
and the impact is negligible given the single-batch read shape. and the impact is negligible given the single-batch read shape.
**Resolution:** Resolved 2026-06-20 — added an inline comment on the `severity` read line in
`Tick` documenting that `severityDv.StatusCode` is not checked, that a Bad Severity read yields
`ToInt(null)=0``MapSeverity` buckets as Low, and that this is acceptable because InFaulted
and Severity are members of the same ALMD UDT read in one batch (a Good InFaulted almost always
implies a Good Severity). No behaviour change.
### Driver.AbCip-018 ### Driver.AbCip-018
| Field | Value | | Field | Value |
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 | | Review date | 2026-06-19 |
| Commit reviewed | `a19b0f86` | | Commit reviewed | `a19b0f86` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 3 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -69,7 +69,7 @@ verified by build (no test project in this module).
| Severity | Low | | Severity | Low |
| Category | Documentation & comments | | Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs:15` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs:15` |
| Status | Open | | Status | Resolved |
**Description:** `AbLegacyEquipmentTagParser.TryParse` has an undocumented edge case: when **Description:** `AbLegacyEquipmentTagParser.TryParse` has an undocumented edge case: when
`isArray` is the JSON literal `true` but `arrayLength` is absent, zero, or negative, `isArray` is the JSON literal `true` but `arrayLength` is absent, zero, or negative,
@@ -85,7 +85,13 @@ without a valid positive `arrayLength` silently produces a scalar (null `ArrayLe
Optionally add a unit test to `AbLegacyEquipmentTagTests` covering Optionally add a unit test to `AbLegacyEquipmentTagTests` covering
`isArray:true, arrayLength:0/absent -> null` for regression protection. `isArray:true, arrayLength:0/absent -> null` for regression protection.
**Resolution:** _(empty until closed)_ **Resolution:** Resolved 2026-06-20. Added a `<remarks>` block to `TryParse` in
`AbLegacyEquipmentTagParser.cs` documenting that `isArray:true` without a valid positive
`arrayLength` silently produces a scalar (`ArrayLength` is `null`), referencing the
in-source review C-2 rationale. Added three regression unit tests to
`AbLegacyEquipmentTagTests`: `IsArray_true_with_arrayLength_zero_produces_scalar`,
`IsArray_true_with_no_arrayLength_produces_scalar`, and
`IsArray_true_with_negative_arrayLength_produces_scalar`. Suite: 199 passed, 0 failed.
--- ---
@@ -96,7 +102,7 @@ Optionally add a unit test to `AbLegacyEquipmentTagTests` covering
| Severity | Low | | Severity | Low |
| Category | OtOpcUa conventions | | Category | OtOpcUa conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:10` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:10` |
| Status | Open | | Status | Resolved |
**Description:** `AbLegacyPlcFamilyProfile.MaxTagBytes` is a record constructor parameter **Description:** `AbLegacyPlcFamilyProfile.MaxTagBytes` is a record constructor parameter
populated with distinct values per family (240/232/240/240), but a global search finds zero populated with distinct values per family (240/232/240/240), but a global search finds zero
@@ -111,7 +117,11 @@ produce off-by-one sizing. If reserved for future clamping, the intent is undocu
clamping and is not currently enforced, or (b) remove the field and its four initialisers clamping and is not currently enforced, or (b) remove the field and its four initialisers
in a dedicated cleanup PR (signature change is out of scope for this review). in a dedicated cleanup PR (signature change is out of scope for this review).
**Resolution:** _(empty until closed)_ **Resolution:** Resolved 2026-06-20. Applied option (a): added XML doc on the `MaxTagBytes`
constructor parameter in `AbLegacyPlcFamilyProfile.cs` noting it is reserved for future
array-length clamping, is NOT currently enforced anywhere in the driver, and that the values
are approximate PCCC packet payload caps (not libplctag fragment limits). Field and
initialisers unchanged.
--- ---
@@ -122,7 +132,7 @@ in a dedicated cleanup PR (signature change is out of scope for this review).
| Severity | Low | | Severity | Low |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:22` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:22` |
| Status | Open | | Status | Resolved |
**Description:** `AbLegacyPlcFamilyProfile.ForFamily` has a catch-all arm `_ => Slc500` **Description:** `AbLegacyPlcFamilyProfile.ForFamily` has a catch-all arm `_ => Slc500`
that silently returns the SLC 500 profile for any unrecognised `AbLegacyPlcFamily` value that silently returns the SLC 500 profile for any unrecognised `AbLegacyPlcFamily` value
@@ -139,4 +149,8 @@ added), or (b) replace `_ => Slc500` with
`_ => throw new ArgumentOutOfRangeException(nameof(family), family, null)` and apply it `_ => throw new ArgumentOutOfRangeException(nameof(family), family, null)` and apply it
in a cleanup PR. Semantic change -- defer. in a cleanup PR. Semantic change -- defer.
**Resolution:** _(empty until closed)_ **Resolution:** Resolved 2026-06-20. Applied option (a): added a `<remarks>` block to the
`ForFamily` XML doc in `AbLegacyPlcFamilyProfile.cs` documenting that any unrecognised
`family` value silently returns the `Slc500` profile, and noting this is intentional for
forward-compatibility with configs authored before a new family enum member is added. The
switch body is unchanged.
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 | | Review date | 2026-06-19 |
| Commit reviewed | `7286d320` | | Commit reviewed | `7286d320` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 1 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -61,13 +61,13 @@ a category produced nothing rather than leaving it blank.
| Severity | Low | | Severity | Low |
| Category | Design-document adherence | | Category | Design-document adherence |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs:139-145` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs:139-145` |
| Status | Open | | Status | Resolved |
**Description:** `FocasTagDefinition` carries a `WriteIdempotent` field (default `false`) that the FOCAS driver never reads anywhere — neither in `DiscoverAsync`, `ReadAsync`, nor `WriteAsync`. `DiscoverAsync` always passes `WriteIdempotent: false` to `DriverAttributeInfo` (hardcoded), so the field has no runtime effect. `docs/drivers/FOCAS.md` does not mention `WriteIdempotent` in its configuration tables, making its purpose undiscoverable to operators. **Description:** `FocasTagDefinition` carries a `WriteIdempotent` field (default `false`) that the FOCAS driver never reads anywhere — neither in `DiscoverAsync`, `ReadAsync`, nor `WriteAsync`. `DiscoverAsync` always passes `WriteIdempotent: false` to `DriverAttributeInfo` (hardcoded), so the field has no runtime effect. `docs/drivers/FOCAS.md` does not mention `WriteIdempotent` in its configuration tables, making its purpose undiscoverable to operators.
**Recommendation:** Either (a) thread `FocasTagDefinition.WriteIdempotent` through to `DriverAttributeInfo` in `DiscoverAsync` so the field has runtime effect and matches the pattern of other drivers, or (b) remove it from the record and the `FocasDriverConfigDto` wire DTO. Option (a) is the safer fix and matches the design of Modbus and other drivers. This is deferred because the fix touches `FocasDriver.DiscoverAsync` outside this module and requires a driver-level test change. **Recommendation:** Either (a) thread `FocasTagDefinition.WriteIdempotent` through to `DriverAttributeInfo` in `DiscoverAsync` so the field has runtime effect and matches the pattern of other drivers, or (b) remove it from the record and the `FocasDriverConfigDto` wire DTO. Option (a) is the safer fix and matches the design of Modbus and other drivers. This is deferred because the fix touches `FocasDriver.DiscoverAsync` outside this module and requires a driver-level test change.
**Resolution:** _(empty until closed)_ **Resolution:** Fixed (option a). In `FocasDriver.DiscoverAsync` the hardcoded `WriteIdempotent: false` was replaced with `tag.WriteIdempotent` so each user-authored tag's per-tag value is now threaded through to `DriverAttributeInfo`. The `<param name="WriteIdempotent">` doc on `FocasTagDefinition` was updated to state it is now threaded through `DiscoverAsync` to `DriverAttributeInfo`. A new test `DiscoverAsync_surfaces_WriteIdempotent_from_tag_definition` was added to `FocasDriverMediumFindingsTests` asserting both the `true` and `false` cases surface correctly. The full `Driver.FOCAS.Tests` suite (including `FocasPmcBitRmwTests` and `FocasReadWriteTests` write tests) passes.
--- ---
+14 -5
View File
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 | | Review date | 2026-06-19 |
| Commit reviewed | `7286d320` | | Commit reviewed | `7286d320` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 1 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -27,7 +27,7 @@ a category produced nothing rather than leaving it blank.
| 5 | Security | No issues found | | 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found | | 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | No issues found | | 7 | Design-document adherence | No issues found |
| 8 | Code organization & conventions | Driver.Galaxy.Browser-003 (Low): ResolveApiKey duplicated from GalaxyDriver with no sync mechanism | | 8 | Code organization & conventions | Driver.Galaxy.Browser-003 (Low, Resolved): ResolveApiKey duplicated from GalaxyDriver; extracted to `GalaxySecretRef` in Driver.Galaxy.Contracts |
| 9 | Testing coverage | Driver.Galaxy.Browser-004 (Low): MapSecurityClass not unit-tested; pure static method with no gateway dependency | | 9 | Testing coverage | Driver.Galaxy.Browser-004 (Low): MapSecurityClass not unit-tested; pure static method with no gateway dependency |
| 10 | Documentation & comments | No issues found | | 10 | Documentation & comments | No issues found |
@@ -119,7 +119,7 @@ mirroring the existing pattern for `_client.DisposeAsync()`. Regression test
| Severity | Low | | Severity | Low |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` |
| Status | Open | | Status | Resolved |
**Description:** `GalaxyDriverBrowser.ResolveApiKey` is a verbatim copy of **Description:** `GalaxyDriverBrowser.ResolveApiKey` is a verbatim copy of
`GalaxyDriver.ResolveApiKey`. The comment acknowledges this and explains why the Browser `GalaxyDriver.ResolveApiKey`. The comment acknowledges this and explains why the Browser
@@ -133,8 +133,17 @@ and emit a spurious warning.
which both `Driver.Galaxy` and `Driver.Galaxy.Browser` already reference. This is a which both `Driver.Galaxy` and `Driver.Galaxy.Browser` already reference. This is a
one-line addition to Contracts — no migration, no public-contract break. one-line addition to Contracts — no migration, no public-contract break.
**Resolution:** _(deferred — requires a change in the Galaxy.Contracts project, outside **Resolution:** Resolved 2026-06-20 — closed by the same fix as the cross-referenced
this module's boundary; tracked for a future consolidation pass)_ Driver.Galaxy.Contracts-003. The resolver was extracted into a new
`public static class GalaxySecretRef` in `Driver.Galaxy.Contracts`
(`ResolveApiKey(string secretRef, ILogger? logger = null)`), which both Driver.Galaxy
and Driver.Galaxy.Browser already reference. `GalaxyDriverBrowser.BuildClientOptions`
now calls `GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger)` and its private
`ResolveApiKey` copy is deleted — eliminating the drift risk (a future `vault:` prefix
is now added in one place). Behaviour is preserved exactly: the Browser's `_logger` is
never null (defaults to `NullLogger`), so the literal-arm cleartext warning still fires.
The Browser project builds clean against the shared resolver. See
Driver.Galaxy.Contracts-003 for the full extraction details.
--- ---
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 | | Review date | 2026-06-19 |
| Commit reviewed | `7286d320` | | Commit reviewed | `7286d320` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 1 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -27,7 +27,7 @@ a category produced nothing rather than leaving it blank.
| 5 | Security | No issues found (ApiKeySecretRef is documented as an indirection reference; no defaults store a cleartext secret; no ToString override leaks the ref) | | 5 | Security | No issues found (ApiKeySecretRef is documented as an indirection reference; no defaults store a cleartext secret; no ToString override leaks the ref) |
| 6 | Performance & resource management | No issues found (pure records; no allocations, disposables, or resource lifetimes) | | 6 | Performance & resource management | No issues found (pure records; no allocations, disposables, or resource lifetimes) |
| 7 | Design-document adherence | No issues found (records match CLAUDE.md Gateway/MxAccess/Repository/Reconnect section layout) | | 7 | Design-document adherence | No issues found (records match CLAUDE.md Gateway/MxAccess/Repository/Reconnect section layout) |
| 8 | Code organization & conventions | Driver.Galaxy.Contracts-003 (Low, Open): ResolveApiKey helper duplicated between GalaxyDriver and GalaxyBrowseSession; Contracts is the natural home | | 8 | Code organization & conventions | Driver.Galaxy.Contracts-003 (Low, Resolved): ResolveApiKey helper duplicated between GalaxyDriver and GalaxyBrowseSession; extracted to `GalaxySecretRef` in Contracts |
| 9 | Testing coverage | No issues found (no logic to test; pure data records with default values) | | 9 | Testing coverage | No issues found (no logic to test; pure data records with default values) |
| 10 | Documentation & comments | Driver.Galaxy.Contracts-001 (Low, Resolved): Internal code-review finding ID `(Driver.Galaxy-010)` in shipped XML doc | | 10 | Documentation & comments | Driver.Galaxy.Contracts-001 (Low, Resolved): Internal code-review finding ID `(Driver.Galaxy-010)` in shipped XML doc |
@@ -108,7 +108,7 @@ note the minimum and that `EventPump` enforces it at construction. Verified by b
| Severity | Low | | Severity | Low |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` (duplicate) and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:472` (original) | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` (duplicate) and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:472` (original) |
| Status | Open | | Status | Resolved |
**Description:** `GalaxyDriver.ResolveApiKey` (the four-form `env:`/`file:`/`dev:`/literal **Description:** `GalaxyDriver.ResolveApiKey` (the four-form `env:`/`file:`/`dev:`/literal
resolver, ~45 LOC) is duplicated verbatim as `GalaxyDriverBrowser.ResolveApiKey`. The resolver, ~45 LOC) is duplicated verbatim as `GalaxyDriverBrowser.ResolveApiKey`. The
@@ -135,6 +135,20 @@ which ships in-box with .NET 10's BCL and adds no new NuGet dependency.
overload) into a `GalaxySecretRef` static class in this project; update both call sites overload) into a `GalaxySecretRef` static class in this project; update both call sites
to delegate to it. to delegate to it.
**Resolution:** _(deferred — cross-module coordination change; Driver.Galaxy and **Resolution:** Resolved 2026-06-20 — extracted the four-form resolver into a new
Driver.Galaxy.Browser must both be updated in the same commit. Tracked for a future `public static class GalaxySecretRef` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs`,
consolidation pass. See also Driver.Galaxy.Browser-003.)_ namespace `ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config`) as a single
`ResolveApiKey(string secretRef, ILogger? logger = null)` method (the two former
`GalaxyDriver` overloads collapsed into one optional-logger signature). The exact
resolution semantics are preserved byte-for-byte: `env:NAME` (throws when unset),
`file:PATH` (throws when missing/empty, trims), `dev:KEY` (literal, no warning), and
the back-compat literal arm (returns the literal and emits the same `Warning` when a
logger is supplied). The Contracts `.csproj` gained a single
`Microsoft.Extensions.Logging.Abstractions` PackageReference for the `ILogger`
parameter. Both call sites now delegate: `GalaxyDriver.BuildClientOptions` calls
`GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger)` and the two private
`GalaxyDriver.ResolveApiKey` overloads are deleted; `GalaxyDriverBrowser.BuildClientOptions`
likewise delegates (passing its non-null `_logger`) and its private copy is deleted. No
migration, no public wire-contract change. Regression coverage: `GalaxyDriverApiKeyResolverTests`
was repointed at `GalaxySecretRef.ResolveApiKey` (all 10 facts green). This finding and
the sibling Driver.Galaxy.Browser-003 are closed by this one extraction.
+23 -19
View File
@@ -7,7 +7,7 @@
| Review date | 2026-06-19 | | Review date | 2026-06-19 |
| Commit reviewed | `7286d320` | | Commit reviewed | `7286d320` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 1 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -83,7 +83,7 @@ observation + single `_ownedRepositoryClient`, the `ResolveApiKey`
estimate, the O(1) `SubscriptionRegistry` indices, and the `ReinitializeAsync` estimate, the O(1) `SubscriptionRegistry` indices, and the `ReinitializeAsync`
equivalent-config gate all survive intact. equivalent-config gate all survive intact.
One new finding (Driver.Galaxy-019, Low, Open-deferred). No Critical / High / One new finding (Driver.Galaxy-019, Low, Resolved 2026-06-20). No Critical / High /
Medium new findings. Category results: Medium new findings. Category results:
| # | Category | Result | | # | Category | Result |
@@ -91,12 +91,12 @@ Medium new findings. Category results:
| 1 | Correctness & logic bugs | No new issues found | | 1 | Correctness & logic bugs | No new issues found |
| 2 | OtOpcUa conventions | No new issues found | | 2 | OtOpcUa conventions | No new issues found |
| 3 | Concurrency & thread safety | No new issues found | | 3 | Concurrency & thread safety | No new issues found |
| 4 | Error handling & resilience | Driver.Galaxy-019 | | 4 | Error handling & resilience | Driver.Galaxy-019 (Resolved) |
| 5 | Security | No new issues found (vendoring retired; no secret logging; `ResolveApiKey` warns on cleartext) | | 5 | Security | No new issues found (vendoring retired; no secret logging; `ResolveApiKey` warns on cleartext) |
| 6 | Performance & resource management | No new issues found | | 6 | Performance & resource management | No new issues found |
| 7 | Design-document adherence | No new issues found | | 7 | Design-document adherence | No new issues found |
| 8 | Code organization & conventions | No new issues found | | 8 | Code organization & conventions | No new issues found |
| 9 | Testing coverage | No new issues found (31 suites; `GatewayGalaxySubscriber` untestable per Driver.Galaxy-019) | | 9 | Testing coverage | No new issues found (31 suites; `GatewayGalaxySubscriber` now has its first unit test — `GatewayGalaxySubscriberClassifyTests` for the Driver.Galaxy-019 classifier) |
| 10 | Documentation & comments | No new issues found | | 10 | Documentation & comments | No new issues found |
## Findings ## Findings
@@ -441,7 +441,7 @@ the existing csproj works correctly.
| Severity | Low | | Severity | Low |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Location | `Runtime/GatewayGalaxySubscriber.cs:89-100` | | Location | `Runtime/GatewayGalaxySubscriber.cs:89-100` |
| Status | Open | | Status | Resolved |
**Description:** `GatewayGalaxySubscriber.EnsureSessionIntervalAsync` applies **Description:** `GatewayGalaxySubscriber.EnsureSessionIntervalAsync` applies
the session-level `SetBufferedUpdateInterval` command and then caches the the session-level `SetBufferedUpdateInterval` command and then caches the
@@ -479,17 +479,21 @@ extract the reply→outcome classification into a pure internal helper
the sealed, internal-ctor `MxGatewaySession``GatewayGalaxySubscriber` the sealed, internal-ctor `MxGatewaySession``GatewayGalaxySubscriber`
currently has zero unit tests for exactly this reason. currently has zero unit tests for exactly this reason.
**Resolution:** Deferred 2026-06-19the fix is small but cannot be landed **Resolution:** Resolved 2026-06-20extracted the pure classifier
with a real TDD red→green cycle in this module: the behaviour lives entirely `internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok`
behind the sealed `MxGatewaySession` (no public/internal ctor; the same and routed `EnsureSessionIntervalAsync` through it, so `_lastAppliedIntervalMs`
constraint that forced every other gw call onto an injectable `IGalaxy*` seam), is now set **only** on `Ok`. `MxaccessFailure` (COM-side set did not apply), any
so there is no way to drive a synthetic `MxCommandReply` through other unexpected code, and a missing status all leave the cache untouched, so the
`EnsureSessionIntervalAsync` without first refactoring out the pure-classifier requested cadence is re-attempted on the next subscribe rather than permanently
helper the recommendation calls for. That refactor plus giving the subscriber a pinned at the gateway default after a transient hiccup. The subscriber gained an
logger is a low-risk but non-trivial change to the production gw session path, optional `ILogger? logger = null` ctor parameter (defaulting to `NullLogger.Instance`),
and the underlying intent (is caching on `MxaccessFailure` deliberate "the threaded through from `BuildProductionRuntimeAsync`'s `_logger` at the one
gateway processed it, don't retry" behaviour, or an oversight?) is a design `new GatewayGalaxySubscriber(_ownedMxSession, _logger)` call site; it now emits a
question better confirmed against the gateway contract than guessed at in a `Warning` on both the `MxaccessFailure` soft-failure path and the unexpected-code
review sweep. Tracked for a follow-up that lands the classifier extraction + early-return path (no secret/key is logged), so the comment's promised signal exists.
its unit tests together. Impact is bounded to a sub-optimal (not broken) Regression coverage: new `GatewayGalaxySubscriberClassifyTests`
publish cadence that self-heals on reconnect. (`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/`) — the subscriber's first
unit test — pins `Ok → true`, `MxaccessFailure → false`, `Unspecified → false`,
`null → false`. The sealed `MxGatewaySession` ctor that previously blocked a TDD
cycle is now sidestepped because the classifier is a pure static helper testable in
isolation.
@@ -25,7 +25,9 @@ public enum AbCipDataType
Real, // 32-bit IEEE-754 Real, // 32-bit IEEE-754
LReal, // 64-bit IEEE-754 LReal, // 64-bit IEEE-754
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag) String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions Dt, // Logix DATE (0xCD — 4-byte unsigned days since 1984-01-01) or DATE_AND_TIME / DT
// (0xCF — 8-byte unsigned microseconds since 1970-01-01). The driver reads 4 bytes
// via GetInt32; DATE decodes correctly, DATE_AND_TIME is truncated to the low 4 bytes.
/// <summary> /// <summary>
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is /// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
/// resolved at discovery time; reads + writes fan out to member Variables unless the /// resolved at discovery time; reads + writes fan out to member Variables unless the
@@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary> /// <summary>
@@ -81,8 +79,7 @@ public sealed class AbCipDriverOptions
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a /// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances. /// 60s server-side maximum; this default is what the form pre-fills for new instances.
/// </summary> /// </summary>
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")] /// <remarks>Valid range: 160 seconds; the AdminUI clamps to 60s server-side.</remarks>
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 5; public int ProbeTimeoutSeconds { get; init; } = 5;
} }
@@ -13,10 +13,11 @@ public static class AbCipEquipmentTagParser
/// <param name="def">The transient definition when parsing succeeds.</param> /// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbCip TagConfig object.</returns> /// <returns><see langword="true"/> when <paramref name="reference"/> is an AbCip TagConfig object.</returns>
/// <remarks> /// <remarks>
/// The produced <see cref="AbCipTagDefinition.Writable"/> is always <c>true</c>: the /// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c>
/// TagConfig JSON format for equipment tags does not carry a writability field, so the /// boolean field in the TagConfig JSON; it defaults to <c>true</c> when the field is absent,
/// PLC's ExternalAccess attribute is the effective write gate. Operators who need a /// matching the record's documented default and the behaviour of pre-declared tags. Operators
/// read-only OPC UA surface must rely on the PLC's ExternalAccess rejecting the write. /// who need a read-only OPC UA surface can author <c>"writable":false</c> in the TagConfig;
/// the PLC's ExternalAccess attribute remains the effective write gate at the wire level.
/// </remarks> /// </remarks>
public static bool TryParse(string reference, out AbCipTagDefinition def) public static bool TryParse(string reference, out AbCipTagDefinition def)
{ {
@@ -36,6 +37,11 @@ public static class AbCipEquipmentTagParser
if (string.IsNullOrWhiteSpace(tagPath)) return false; if (string.IsNullOrWhiteSpace(tagPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress"); var deviceHostAddress = ReadString(root, "deviceHostAddress");
// A "dataType":"Structure" input is accepted and produces a Structure-typed definition
// with Members:null. The driver treats this as a black-box dotted-path read: libplctag
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
// The address space emits a placeholder String variable; UDT member declarations are
// not supported in the equipment-tag flow.
var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt); var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt);
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A // Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the // 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
@@ -43,9 +49,12 @@ public static class AbCipEquipmentTagParser
// scalar. ElementCount can't carry the signal (a scalar and a 1-element array both // scalar. ElementCount can't carry the signal (a scalar and a 1-element array both
// have a count of 1), so the explicit IsArray flag does. // have a count of 1), so the explicit IsArray flag does.
var (isArray, elementCount) = ReadArrayShape(root); var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
var writable = !root.TryGetProperty("writable", out var writableEl)
|| writableEl.ValueKind != JsonValueKind.False;
def = new AbCipTagDefinition( def = new AbCipTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath, Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
DataType: dataType, Writable: true, ElementCount: elementCount, IsArray: isArray); DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
return true; return true;
} }
catch (JsonException) { return false; } catch (JsonException) { return false; }
@@ -181,6 +181,11 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue; if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
var nowFaulted = ToBool(inFaultedDv.Value); var nowFaulted = ToBool(inFaultedDv.Value);
// severityDv.StatusCode is not checked here. When the Severity read is Bad (value null),
// ToInt(null) returns 0 and MapSeverity buckets it as Low. This is acceptable because
// InFaulted and Severity are members of the same ALMD UDT read in one batch, so a Good
// InFaulted almost always implies a Good Severity. The "unknown severity → Low" fallback
// is intentional and matches the behaviour documented on Driver.AbCip-017.
var severity = ToInt(severityDv.Value); var severity = ToInt(severityDv.Value);
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false); var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
@@ -12,6 +12,17 @@ public static class AbLegacyEquipmentTagParser
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param> /// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param> /// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbLegacy address object.</returns> /// <returns><see langword="true"/> when <paramref name="reference"/> is an AbLegacy address object.</returns>
/// <remarks>
/// <para>
/// When <c>isArray</c> is the JSON literal <see langword="true"/> but <c>arrayLength</c> is
/// absent, zero, or negative, the result is silently a <b>scalar</b>
/// (<see cref="AbLegacyTagDefinition.ArrayLength"/> is <see langword="null"/>).
/// A valid positive <c>arrayLength</c> is required to produce an array tag; <c>isArray:true</c>
/// alone is not sufficient. This is intentional: a stale length behind a cleared or absent
/// <c>isArray</c> flag must never produce an orphan array tag that mismatches its scalar OPC UA
/// node (see in-source comment, review C-2).
/// </para>
/// </remarks>
public static bool TryParse(string reference, out AbLegacyTagDefinition def) public static bool TryParse(string reference, out AbLegacyTagDefinition def)
{ {
def = null!; def = null!;
@@ -7,12 +7,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
public sealed record AbLegacyPlcFamilyProfile( public sealed record AbLegacyPlcFamilyProfile(
string LibplctagPlcAttribute, string LibplctagPlcAttribute,
string DefaultCipPath, string DefaultCipPath,
/// <summary>
/// Reserved for future array-length clamping. <b>Not currently enforced anywhere in the
/// driver.</b> The values are approximate upper bounds derived from PCCC packet payload
/// limits (e.g. SLC 5/05 240 bytes is the PCCC-over-EIP data cap, not a libplctag fragment
/// limit). Do not rely on this field for sizing decisions until an enforcement point is added.
/// </summary>
int MaxTagBytes, int MaxTagBytes,
bool SupportsStringFile, bool SupportsStringFile,
bool SupportsLongFile) bool SupportsLongFile)
{ {
/// <summary>Gets the profile for the specified PLC family.</summary> /// <summary>Gets the profile for the specified PLC family.</summary>
/// <param name="family">The PLC family.</param> /// <param name="family">The PLC family.</param>
/// <remarks>
/// Any unrecognised <paramref name="family"/> value (e.g. an integer cast to the enum, or a
/// value added to <see cref="AbLegacyPlcFamily"/> before this switch is updated) silently
/// returns the <see cref="Slc500"/> profile. This is intentional: it preserves
/// forward-compatibility for device configs authored against a build that predates a new
/// family enum member, preferring a safe default over a startup exception.
/// </remarks>
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
{ {
AbLegacyPlcFamily.Slc500 => Slc500, AbLegacyPlcFamily.Slc500 => Slc500,
@@ -157,9 +157,9 @@ public sealed record FocasDeviceOptions(
/// <c>FocasReadWriteTests</c>). Defaults to <c>true</c>. /// <c>FocasReadWriteTests</c>). Defaults to <c>true</c>.
/// </param> /// </param>
/// <param name="WriteIdempotent"> /// <param name="WriteIdempotent">
/// Whether repeated writes of the same value are safe. Carried for parity; not yet /// Whether repeated writes of the same value are safe. Threaded through to
/// threaded through to <c>DriverAttributeInfo</c> in <c>DiscoverAsync</c> (see /// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
/// Driver.FOCAS.Contracts-002). Defaults to <c>false</c>. /// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
/// </param> /// </param>
public sealed record FocasTagDefinition( public sealed record FocasTagDefinition(
string Name, string Name,
@@ -487,7 +487,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
SecurityClass: SecurityClassification.ViewOnly, SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false, IsHistorized: false,
IsAlarm: false, IsAlarm: false,
WriteIdempotent: false)); WriteIdempotent: tag.WriteIdempotent));
} }
} }
return Task.CompletedTask; return Task.CompletedTask;
@@ -122,14 +122,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
/// <summary> /// <summary>
/// Build the gateway client options from the form's Gateway section. Mirrors the /// Build the gateway client options from the form's Gateway section. Mirrors the
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the /// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
/// gateway sees an identical option shape. The API-key reference is resolved /// gateway sees an identical option shape. The API-key reference is resolved via
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the /// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
/// Browser project doesn't reference Driver.Galaxy. /// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
/// </summary> /// </summary>
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
{ {
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = ResolveApiKey(gw.ApiKeySecretRef), ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
UseTls = gw.UseTls, UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath, CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
@@ -138,57 +138,4 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
: null, : null,
}; };
/// <summary>
/// Resolves <c>env:NAME</c>, <c>file:PATH</c>, and <c>dev:KEY</c> prefixes;
/// anything else is treated as a literal cleartext key with a startup warning.
/// Slim mirror of <c>GalaxyDriver.ResolveApiKey</c> — the runtime version lives
/// in a sibling project the Browser intentionally doesn't reference.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
private string ResolveApiKey(string secretRef)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it when they open the address picker.
_logger.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
} }
@@ -30,7 +30,7 @@ public sealed record GalaxyDriverOptions(
/// <summary> /// <summary>
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is /// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
/// resolved by <c>GalaxyDriver.ResolveApiKey</c> at InitializeAsync time. Four forms /// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
/// supported, in priority order: /// supported, in priority order:
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>env:NAME</c> — read from an environment variable (recommended for /// <item><c>env:NAME</c> — read from an environment variable (recommended for
@@ -0,0 +1,88 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
/// forms supported, evaluated in order:
/// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
/// Throws when the variable is unset, so a misconfigured deployment fails
/// fast rather than silently sending an empty key.</item>
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
/// whitespace. Lets operators stash the key in an ACL'd file outside the
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
/// doesn't emit a warning; production should never use this arm.</item>
/// <item>Anything else — used as the literal API key for back-compat with
/// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it (Driver.Galaxy-010).</item>
/// </list>
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
/// changing the call site.
/// </summary>
/// <remarks>
/// Lives in the Contracts project so both the runtime <c>GalaxyDriver</c> and the
/// AdminUI <c>GalaxyDriverBrowser</c> (which intentionally don't reference each
/// other) share a single resolver rather than each maintaining a copy.
/// </remarks>
public static class GalaxySecretRef
{
/// <summary>
/// Resolves the supplied secret reference. When the ref falls through to the
/// back-compat literal arm (an unprefixed cleartext API key in
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
/// opt-in path that doesn't warn.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
// this warning when the literal is intentional.
logger?.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
}
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. --> <!-- NO ProjectReference. The only PackageReference is the logging abstraction -->
<!-- needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project> </Project>
@@ -252,7 +252,7 @@ public sealed class GalaxyDriver
// listener (OTLP exporter, dotnet-trace, etc.) consumes these without the driver // listener (OTLP exporter, dotnet-trace, etc.) consumes these without the driver
// taking a dependency on the OpenTelemetry packages. // taking a dependency on the OpenTelemetry packages.
_subscriber = new TracedGalaxySubscriber( _subscriber = new TracedGalaxySubscriber(
new GatewayGalaxySubscriber(_ownedMxSession), _options.MxAccess.ClientName); new GatewayGalaxySubscriber(_ownedMxSession, _logger), _options.MxAccess.ClientName);
_dataWriter = new TracedGalaxyDataWriter( _dataWriter = new TracedGalaxyDataWriter(
// Let the writer borrow live MXAccess item handles the subscription registry already // Let the writer borrow live MXAccess item handles the subscription registry already
// holds, so the first write to an already-subscribed tag skips a redundant AddItem. // holds, so the first write to an already-subscribed tag skips a redundant AddItem.
@@ -437,91 +437,14 @@ public sealed class GalaxyDriver
} }
} }
/// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key bytes. Four
/// forms supported, evaluated in order:
/// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
/// Throws when the variable is unset, so a misconfigured deployment fails
/// fast at InitializeAsync rather than silently sending an empty key.</item>
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
/// whitespace. Lets operators stash the key in an ACL'd file outside the
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
/// doesn't emit a warning; production should never use this arm.</item>
/// <item>Anything else — used as the literal API key for back-compat with
/// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it (Driver.Galaxy-010).</item>
/// </list>
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
/// changing the call site.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
internal static string ResolveApiKey(string secretRef) => ResolveApiKey(secretRef, logger: null);
/// <summary>
/// Logger-aware overload. Emits a <see cref="LogLevel.Warning"/> if the secret
/// ref falls through to the back-compat literal arm (an unprefixed cleartext
/// API key in <c>DriverConfig</c> JSON). The <c>dev:</c> prefix is the explicit
/// opt-in path that doesn't warn.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
internal static string ResolveApiKey(string secretRef, ILogger? logger)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it at startup. Use the dev: prefix
// to suppress this warning when the literal is intentional.
logger?.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
{ {
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
// Driver.Galaxy-010: pass the logger so the literal-arm cleartext fallback // Driver.Galaxy-010: pass the logger so the literal-arm cleartext fallback
// surfaces a startup warning rather than silently shipping the key. // surfaces a startup warning rather than silently shipping the key. The
ApiKey = ResolveApiKey(gw.ApiKeySecretRef, _logger), // resolver lives in Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime
// driver and the AdminUI browser share one implementation.
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
UseTls = gw.UseTls, UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath, CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check. // Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
@@ -18,14 +20,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
public sealed class GatewayGalaxySubscriber : IGalaxySubscriber public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
{ {
private readonly GalaxyMxSession _session; private readonly GalaxyMxSession _session;
private readonly ILogger _logger;
private readonly Lock _intervalLock = new(); private readonly Lock _intervalLock = new();
private int _lastAppliedIntervalMs = -1; // -1 = never applied; 0 = explicit "use gw default" private int _lastAppliedIntervalMs = -1; // -1 = never applied; 0 = explicit "use gw default"
/// <summary>Initializes a new instance of GatewayGalaxySubscriber.</summary> /// <summary>Initializes a new instance of GatewayGalaxySubscriber.</summary>
/// <param name="session">The Galaxy MX session to use for subscription operations.</param> /// <param name="session">The Galaxy MX session to use for subscription operations.</param>
public GatewayGalaxySubscriber(GalaxyMxSession session) /// <param name="logger">
/// Optional logger; surfaces a warning when <c>SetBufferedUpdateInterval</c>
/// soft-fails so the cadence-not-applied condition isn't silent. Null is allowed
/// for unit-test construction and falls back to <see cref="NullLogger.Instance"/>.
/// </param>
public GatewayGalaxySubscriber(GalaxyMxSession session, ILogger? logger = null)
{ {
_session = session ?? throw new ArgumentNullException(nameof(session)); _session = session ?? throw new ArgumentNullException(nameof(session));
_logger = logger ?? NullLogger.Instance;
} }
/// <summary>Subscribes to a bulk list of Galaxy references with optional buffered update interval.</summary> /// <summary>Subscribes to a bulk list of Galaxy references with optional buffered update interval.</summary>
@@ -86,11 +95,33 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
}, },
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (reply.ProtocolStatus?.Code is not (ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure)) var code = reply.ProtocolStatus?.Code;
// MxaccessFailure means the COM-side SetBufferedUpdateInterval did NOT apply, so
// we must NOT cache the requested value — caching it would suppress the retry on
// the next subscribe at this interval. Only Ok records the value as applied.
if (!ClassifyIntervalReply(code))
{ {
// Don't throw on a soft failure — the SubscribeBulk will still succeed at the // Don't throw on a soft failure — the SubscribeBulk will still succeed at the
// gw's default cadence, which is functional just not the requested cadence. // gw's default cadence, which is functional just not the requested cadence.
// The trace span (PR 6.1) plus the warning here gives ops the signal. // The trace span (PR 6.1) plus this warning gives ops the signal, and leaving
// _lastAppliedIntervalMs unchanged lets the next subscribe re-attempt the set.
if (code == ProtocolStatusCode.MxaccessFailure)
{
_logger.LogWarning(
"Galaxy SetBufferedUpdateInterval({IntervalMs}ms) soft-failed (MxaccessFailure); " +
"buffered subscriptions on server handle {ServerHandle} will publish at the gateway's " +
"default cadence. The requested interval was not cached, so a later subscribe will retry it.",
intervalMs, serverHandle);
}
else
{
_logger.LogWarning(
"Galaxy SetBufferedUpdateInterval({IntervalMs}ms) returned an unexpected protocol status " +
"{Code} on server handle {ServerHandle}; treating it as not-applied and leaving the " +
"requested interval uncached so a later subscribe retries it.",
intervalMs, code, serverHandle);
}
return; return;
} }
@@ -100,6 +131,17 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
} }
} }
/// <summary>
/// Classifies a <c>SetBufferedUpdateInterval</c> reply: returns <c>true</c> only when
/// the requested interval was actually applied and may be cached as last-applied.
/// This is <see cref="ProtocolStatusCode.Ok"/> alone — <see cref="ProtocolStatusCode.MxaccessFailure"/>
/// means the COM-side set did NOT take effect (so caching it would prevent a retry),
/// and a <c>null</c> or any other unexpected code is treated as not-applied.
/// </summary>
/// <param name="code">The protocol status code from the gateway reply, or <c>null</c> when absent.</param>
/// <returns><c>true</c> if the interval should be recorded as applied; otherwise <c>false</c>.</returns>
internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok;
/// <summary>Unsubscribes from a bulk list of item handles.</summary> /// <summary>Unsubscribes from a bulk list of item handles.</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param> /// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
@@ -0,0 +1,142 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Dedicated unit tests for <see cref="AbCipEquipmentTagParser.TryParse"/>.
/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array,
/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath,
/// the <c>writable</c> field, and the <c>Structure</c> dataType path (Driver.AbCip.Contracts-004).
/// </summary>
[Trait("Category", "Unit")]
public class AbCipEquipmentTagParserTests
{
// ── Happy-path scalar ────────────────────────────────────────────────────────────────
[Fact]
public void Valid_scalar_round_trip_parses_all_fields()
{
var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.TagPath.ShouldBe("Motor.Speed");
def.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
def.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Array shape ──────────────────────────────────────────────────────────────────────
[Fact]
public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar()
{
var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void N_element_array_isArray_true_arrayLength_N_parses_correctly()
{
var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(8);
}
[Fact]
public void IsArray_true_arrayLength_0_is_canonical_scalar()
{
// Canonical rule: isArray:true AND arrayLength < 1 → scalar.
var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void IsArray_true_arrayLength_absent_is_canonical_scalar()
{
// Canonical rule: isArray:true but arrayLength absent → scalar.
var json = """{"tagPath":"PT_101","isArray":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Rejection paths ──────────────────────────────────────────────────────────────────
[Fact]
public void Non_JSON_input_returns_false()
=> AbCipEquipmentTagParser.TryParse("not json at all", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_array_returns_false()
=> AbCipEquipmentTagParser.TryParse("""["tagPath","foo"]""", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_string_returns_false()
=> AbCipEquipmentTagParser.TryParse("\"Motor.Speed\"", out _).ShouldBeFalse();
[Fact]
public void Missing_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"dataType":"DInt"}""", out _).ShouldBeFalse();
[Fact]
public void Blank_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":" "}""", out _).ShouldBeFalse();
[Fact]
public void TagPath_as_number_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
// ── Writable field (Driver.AbCip.Contracts-001) ───────────────────────────────────────
[Fact]
public void Writable_false_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":false}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
var json = """{"tagPath":"Sensor.Val"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_true_explicit_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
// ── Structure dataType (Driver.AbCip.Contracts-001 Structure concern) ─────────────────
/// <summary>
/// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed
/// definition with Members:null. The driver treats the tag path as a black-box dotted-path
/// read (libplctag resolves the full path); UDT member declarations are not supported in the
/// equipment-tag flow. This test documents current behaviour so a future change to reject
/// Structure is a conscious choice.
/// </summary>
[Fact]
public void Structure_dataType_is_accepted_with_null_Members_and_returns_true()
{
var json = """{"tagPath":"Motor","dataType":"Structure"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(AbCipDataType.Structure);
def.Members.ShouldBeNull();
def.TagPath.ShouldBe("Motor");
}
}
@@ -37,6 +37,32 @@ public sealed class AbLegacyEquipmentTagTests
=> AbLegacyEquipmentTagParser.TryParse( => AbLegacyEquipmentTagParser.TryParse(
"""{"address":"","dataType":"Int"}""", out _).ShouldBeFalse(); """{"address":"","dataType":"Int"}""", out _).ShouldBeFalse();
// -002 regression: isArray:true without a valid positive arrayLength → scalar (null ArrayLength).
[Fact]
public void IsArray_true_with_arrayLength_zero_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":0}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
[Fact]
public void IsArray_true_with_no_arrayLength_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
[Fact]
public void IsArray_true_with_negative_arrayLength_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":-5}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
/// <summary> /// <summary>
/// End-to-end driver-level proof: an AbLegacy driver with NO authored tags can still read an /// End-to-end driver-level proof: an AbLegacy driver with NO authored tags can still read an
/// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient /// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
@@ -116,6 +116,39 @@ public sealed class FocasDriverMediumFindingsTests
.ShouldBe(SecurityClassification.ViewOnly); .ShouldBe(SecurityClassification.ViewOnly);
} }
// ---- Driver.FOCAS.Contracts-002: WriteIdempotent threaded through DiscoverAsync ----
/// <summary>
/// Verifies that a tag authored with <c>WriteIdempotent: true</c> surfaces
/// <c>WriteIdempotent == true</c> on its discovered <see cref="DriverAttributeInfo"/>,
/// and that a tag with the default <c>WriteIdempotent: false</c> surfaces <c>false</c>.
/// Resolves Driver.FOCAS.Contracts-002 — the field was previously hardcoded to
/// <c>false</c> in <c>DiscoverAsync</c> and had no runtime effect.
/// </summary>
[Fact]
public async Task DiscoverAsync_surfaces_WriteIdempotent_from_tag_definition()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
[
new FocasTagDefinition("Idempotent", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, WriteIdempotent: true),
new FocasTagDefinition("NonIdempotent", "focas://10.0.0.5:8193", "R200", FocasDataType.Int16, WriteIdempotent: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "Idempotent").Info.WriteIdempotent
.ShouldBeTrue("a tag declared WriteIdempotent:true must surface true on DriverAttributeInfo");
builder.Variables.Single(v => v.BrowseName == "NonIdempotent").Info.WriteIdempotent
.ShouldBeFalse("a tag declared WriteIdempotent:false (the default) must surface false");
}
// ---- Driver.FOCAS-005: Volatile-guarded _health survives concurrent reads ---- // ---- Driver.FOCAS-005: Volatile-guarded _health survives concurrent reads ----
/// <summary>Verifies that GetHealth reflects state updated from concurrent reads.</summary> /// <summary>Verifies that GetHealth reflects state updated from concurrent reads.</summary>
@@ -1,14 +1,17 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary> /// <summary>
/// Follow-up #2 — pins the three resolution forms supported by /// Follow-up #2 — pins the three resolution forms supported by
/// <see cref="GalaxyDriver.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>, /// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// and the literal-string fallback. A future DPAPI arm slots in here without /// and the literal-string fallback. A future DPAPI arm slots in here without
/// touching the call site. /// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
/// driver and the AdminUI browser share one copy.)
/// </summary> /// </summary>
public sealed class GalaxyDriverApiKeyResolverTests public sealed class GalaxyDriverApiKeyResolverTests
{ {
@@ -16,7 +19,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
[Fact] [Fact]
public void Literal_string_is_returned_unchanged() public void Literal_string_is_returned_unchanged()
{ {
GalaxyDriver.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key"); GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
} }
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary> /// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
@@ -27,7 +30,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
Environment.SetEnvironmentVariable(name, "key-from-env"); Environment.SetEnvironmentVariable(name, "key-from-env");
try try
{ {
GalaxyDriver.ResolveApiKey($"env:{name}").ShouldBe("key-from-env"); GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
} }
finally finally
{ {
@@ -43,7 +46,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
Environment.SetEnvironmentVariable(name, null); Environment.SetEnvironmentVariable(name, null);
var ex = Should.Throw<InvalidOperationException>(() => var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"env:{name}")); GalaxySecretRef.ResolveApiKey($"env:{name}"));
ex.Message.ShouldContain(name); ex.Message.ShouldContain(name);
ex.Message.ShouldContain("unset"); ex.Message.ShouldContain("unset");
} }
@@ -56,7 +59,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
File.WriteAllText(path, " key-from-file \n"); File.WriteAllText(path, " key-from-file \n");
try try
{ {
GalaxyDriver.ResolveApiKey($"file:{path}").ShouldBe("key-from-file"); GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
} }
finally finally
{ {
@@ -70,7 +73,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
{ {
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt"); var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = Should.Throw<InvalidOperationException>(() => var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}")); GalaxySecretRef.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain(path); ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist"); ex.Message.ShouldContain("doesn't exist");
} }
@@ -85,7 +88,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// in the DriverConfig JSON. The resolver must surface a warning so an // in the DriverConfig JSON. The resolver must surface a warning so an
// operator who committed one by accident sees it at startup. // operator who committed one by accident sees it at startup.
var logger = new CaptureLogger(); var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("plain-text-key", logger); var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
key.ShouldBe("plain-text-key"); key.ShouldBe("plain-text-key");
logger.Entries.ShouldContain(e => logger.Entries.ShouldContain(e =>
@@ -100,7 +103,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// key (dev / parity rig). The resolver must accept it AND suppress the // key (dev / parity rig). The resolver must accept it AND suppress the
// warning so production logs aren't polluted on a deliberate dev choice. // warning so production logs aren't polluted on a deliberate dev choice.
var logger = new CaptureLogger(); var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("dev:plain-text-key", logger); var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
key.ShouldBe("plain-text-key"); key.ShouldBe("plain-text-key");
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
@@ -115,7 +118,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
try try
{ {
var logger = new CaptureLogger(); var logger = new CaptureLogger();
GalaxyDriver.ResolveApiKey($"env:{name}", logger); GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
} }
finally finally
@@ -150,7 +153,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
try try
{ {
var ex = Should.Throw<InvalidOperationException>(() => var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}")); GalaxySecretRef.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain("empty"); ex.Message.ShouldContain("empty");
} }
finally finally
@@ -0,0 +1,45 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Driver.Galaxy-019 — pins <see cref="GatewayGalaxySubscriber.ClassifyIntervalReply"/>:
/// a <c>SetBufferedUpdateInterval</c> reply is only "applied" (and therefore cacheable
/// as the last-applied interval) when the gateway returns <see cref="ProtocolStatusCode.Ok"/>.
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> means the COM-side set did NOT take
/// effect, so caching it would suppress the retry on the next subscribe — it must classify
/// as not-applied, as must any other unexpected code or a missing status.
/// </summary>
public sealed class GatewayGalaxySubscriberClassifyTests
{
/// <summary>Ok is the only code that records the interval as applied.</summary>
[Fact]
public void Ok_classifies_as_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.Ok).ShouldBeTrue();
}
/// <summary>MxaccessFailure must NOT cache — the COM-side set did not apply.</summary>
[Fact]
public void MxaccessFailure_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.MxaccessFailure).ShouldBeFalse();
}
/// <summary>Any other unexpected code is treated as not-applied.</summary>
[Fact]
public void Unexpected_code_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.Unspecified).ShouldBeFalse();
}
/// <summary>A missing protocol status (null) is treated as not-applied.</summary>
[Fact]
public void Null_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(null).ShouldBeFalse();
}
}