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:
@@ -11,7 +11,7 @@
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `a19b0f86` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 4 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -44,7 +44,7 @@ a category produced nothing rather than leaving it blank.
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| 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
|
||||
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
|
||||
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 |
|
||||
| Category | Correctness & logic bugs |
|
||||
| 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:
|
||||
|
||||
@@ -114,7 +121,9 @@ Dt, // Logix DATE (0xCD — 4-byte unsigned days since 1984-01-01) or DATE_A
|
||||
|
||||
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 |
|
||||
| Category | Code organization & conventions |
|
||||
| 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)]`
|
||||
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
|
||||
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: 1–60 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 |
|
||||
| Category | Testing coverage |
|
||||
| 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`
|
||||
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
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `7286d320` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -337,7 +337,7 @@ flag and the legacy `ElementCount > 1` paths opt out). Full suite green (303 tes
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `AbCipAlarmProjection.cs:173-185` (`Tick`) |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `AbCipAlarmProjection.Tick` gates each node on the `InFaulted` snapshot's
|
||||
`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,
|
||||
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
|
||||
|
||||
| Field | Value |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `a19b0f86` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 3 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -69,7 +69,7 @@ verified by build (no test project in this module).
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| 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
|
||||
`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
|
||||
`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 |
|
||||
| Category | OtOpcUa conventions |
|
||||
| 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
|
||||
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
|
||||
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 |
|
||||
| Category | Code organization & conventions |
|
||||
| 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`
|
||||
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
|
||||
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 |
|
||||
| Commit reviewed | `7286d320` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -61,13 +61,13 @@ a category produced nothing rather than leaving it blank.
|
||||
| Severity | Low |
|
||||
| Category | Design-document adherence |
|
||||
| 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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `7286d320` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -27,7 +27,7 @@ a category produced nothing rather than leaving it blank.
|
||||
| 5 | Security | No issues found |
|
||||
| 6 | Performance & resource management | 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 |
|
||||
| 10 | Documentation & comments | No issues found |
|
||||
|
||||
@@ -119,7 +119,7 @@ mirroring the existing pattern for `_client.DisposeAsync()`. Regression test
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| 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
|
||||
`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
|
||||
one-line addition to Contracts — no migration, no public-contract break.
|
||||
|
||||
**Resolution:** _(deferred — requires a change in the Galaxy.Contracts project, outside
|
||||
this module's boundary; tracked for a future consolidation pass)_
|
||||
**Resolution:** Resolved 2026-06-20 — closed by the same fix as the cross-referenced
|
||||
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 |
|
||||
| Commit reviewed | `7286d320` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `GalaxyDriver.ResolveApiKey` (the four-form `env:`/`file:`/`dev:`/literal
|
||||
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
|
||||
to delegate to it.
|
||||
|
||||
**Resolution:** _(deferred — cross-module coordination change; Driver.Galaxy and
|
||||
Driver.Galaxy.Browser must both be updated in the same commit. Tracked for a future
|
||||
consolidation pass. See also Driver.Galaxy.Browser-003.)_
|
||||
**Resolution:** Resolved 2026-06-20 — extracted the four-form resolver into a new
|
||||
`public static class GalaxySecretRef` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs`,
|
||||
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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `7286d320` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -83,7 +83,7 @@ observation + single `_ownedRepositoryClient`, the `ResolveApiKey`
|
||||
estimate, the O(1) `SubscriptionRegistry` indices, and the `ReinitializeAsync`
|
||||
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:
|
||||
|
||||
| # | Category | Result |
|
||||
@@ -91,12 +91,12 @@ Medium new findings. Category results:
|
||||
| 1 | Correctness & logic bugs | No new issues found |
|
||||
| 2 | OtOpcUa conventions | 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) |
|
||||
| 6 | Performance & resource management | No new issues found |
|
||||
| 7 | Design-document adherence | 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 |
|
||||
|
||||
## Findings
|
||||
@@ -441,7 +441,7 @@ the existing csproj works correctly.
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `Runtime/GatewayGalaxySubscriber.cs:89-100` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `GatewayGalaxySubscriber.EnsureSessionIntervalAsync` applies
|
||||
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`
|
||||
currently has zero unit tests for exactly this reason.
|
||||
|
||||
**Resolution:** Deferred 2026-06-19 — the fix is small but cannot be landed
|
||||
with a real TDD red→green cycle in this module: the behaviour lives entirely
|
||||
behind the sealed `MxGatewaySession` (no public/internal ctor; the same
|
||||
constraint that forced every other gw call onto an injectable `IGalaxy*` seam),
|
||||
so there is no way to drive a synthetic `MxCommandReply` through
|
||||
`EnsureSessionIntervalAsync` without first refactoring out the pure-classifier
|
||||
helper the recommendation calls for. That refactor plus giving the subscriber a
|
||||
logger is a low-risk but non-trivial change to the production gw session path,
|
||||
and the underlying intent (is caching on `MxaccessFailure` deliberate "the
|
||||
gateway processed it, don't retry" behaviour, or an oversight?) is a design
|
||||
question better confirmed against the gateway contract than guessed at in a
|
||||
review sweep. Tracked for a follow-up that lands the classifier extraction +
|
||||
its unit tests together. Impact is bounded to a sub-optimal (not broken)
|
||||
publish cadence that self-heals on reconnect.
|
||||
**Resolution:** Resolved 2026-06-20 — extracted the pure classifier
|
||||
`internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok`
|
||||
and routed `EnsureSessionIntervalAsync` through it, so `_lastAppliedIntervalMs`
|
||||
is now set **only** on `Ok`. `MxaccessFailure` (COM-side set did not apply), any
|
||||
other unexpected code, and a missing status all leave the cache untouched, so the
|
||||
requested cadence is re-attempted on the next subscribe rather than permanently
|
||||
pinned at the gateway default after a transient hiccup. The subscriber gained an
|
||||
optional `ILogger? logger = null` ctor parameter (defaulting to `NullLogger.Instance`),
|
||||
threaded through from `BuildProductionRuntimeAsync`'s `_logger` at the one
|
||||
`new GatewayGalaxySubscriber(_ownedMxSession, _logger)` call site; it now emits a
|
||||
`Warning` on both the `MxaccessFailure` soft-failure path and the unexpected-code
|
||||
early-return path (no secret/key is logged), so the comment's promised signal exists.
|
||||
Regression coverage: new `GatewayGalaxySubscriberClassifyTests`
|
||||
(`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
|
||||
LReal, // 64-bit IEEE-754
|
||||
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>
|
||||
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
|
||||
/// 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;
|
||||
|
||||
/// <summary>
|
||||
@@ -81,8 +79,7 @@ public sealed class AbCipDriverOptions
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
/// <remarks>Valid range: 1–60 seconds; the AdminUI clamps to 60s server-side.</remarks>
|
||||
public int ProbeTimeoutSeconds { get; init; } = 5;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ public static class AbCipEquipmentTagParser
|
||||
/// <param name="def">The transient definition when parsing succeeds.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbCip TagConfig object.</returns>
|
||||
/// <remarks>
|
||||
/// The produced <see cref="AbCipTagDefinition.Writable"/> is always <c>true</c>: the
|
||||
/// TagConfig JSON format for equipment tags does not carry a writability field, so the
|
||||
/// PLC's ExternalAccess attribute is the effective write gate. Operators who need a
|
||||
/// read-only OPC UA surface must rely on the PLC's ExternalAccess rejecting the write.
|
||||
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c>
|
||||
/// boolean field in the TagConfig JSON; it defaults to <c>true</c> when the field is absent,
|
||||
/// matching the record's documented default and the behaviour of pre-declared tags. Operators
|
||||
/// 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>
|
||||
public static bool TryParse(string reference, out AbCipTagDefinition def)
|
||||
{
|
||||
@@ -36,6 +37,11 @@ public static class AbCipEquipmentTagParser
|
||||
if (string.IsNullOrWhiteSpace(tagPath)) return false;
|
||||
|
||||
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);
|
||||
// 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
|
||||
@@ -43,9 +49,12 @@ public static class AbCipEquipmentTagParser
|
||||
// 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.
|
||||
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(
|
||||
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
|
||||
DataType: dataType, Writable: true, ElementCount: elementCount, IsArray: isArray);
|
||||
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
|
||||
@@ -181,6 +181,11 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
|
||||
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 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="def">The transient definition when parsing succeeds.</param>
|
||||
/// <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)
|
||||
{
|
||||
def = null!;
|
||||
|
||||
@@ -7,12 +7,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
public sealed record AbLegacyPlcFamilyProfile(
|
||||
string LibplctagPlcAttribute,
|
||||
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,
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
{
|
||||
/// <summary>Gets the profile for the specified PLC family.</summary>
|
||||
/// <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
|
||||
{
|
||||
AbLegacyPlcFamily.Slc500 => Slc500,
|
||||
|
||||
@@ -157,9 +157,9 @@ public sealed record FocasDeviceOptions(
|
||||
/// <c>FocasReadWriteTests</c>). Defaults to <c>true</c>.
|
||||
/// </param>
|
||||
/// <param name="WriteIdempotent">
|
||||
/// Whether repeated writes of the same value are safe. Carried for parity; not yet
|
||||
/// threaded through to <c>DriverAttributeInfo</c> in <c>DiscoverAsync</c> (see
|
||||
/// Driver.FOCAS.Contracts-002). Defaults to <c>false</c>.
|
||||
/// Whether repeated writes of the same value are safe. Threaded through to
|
||||
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
|
||||
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
||||
/// </param>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
|
||||
@@ -487,7 +487,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -122,14 +122,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// gateway sees an identical option shape. The API-key reference is resolved
|
||||
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the
|
||||
/// Browser project doesn't reference Driver.Galaxy.
|
||||
/// gateway sees an identical option shape. The API-key reference is resolved via
|
||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
|
||||
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
||||
/// </summary>
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
@@ -138,57 +138,4 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: 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>
|
||||
/// 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:
|
||||
/// <list type="bullet">
|
||||
/// <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
-1
@@ -5,5 +5,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</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>
|
||||
|
||||
@@ -252,7 +252,7 @@ public sealed class GalaxyDriver
|
||||
// listener (OTLP exporter, dotnet-trace, etc.) consumes these without the driver
|
||||
// taking a dependency on the OpenTelemetry packages.
|
||||
_subscriber = new TracedGalaxySubscriber(
|
||||
new GatewayGalaxySubscriber(_ownedMxSession), _options.MxAccess.ClientName);
|
||||
new GatewayGalaxySubscriber(_ownedMxSession, _logger), _options.MxAccess.ClientName);
|
||||
_dataWriter = new TracedGalaxyDataWriter(
|
||||
// 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.
|
||||
@@ -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()
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
// Driver.Galaxy-010: pass the logger so the literal-arm cleartext fallback
|
||||
// surfaces a startup warning rather than silently shipping the key.
|
||||
ApiKey = ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
// surfaces a startup warning rather than silently shipping the key. The
|
||||
// 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,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
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.Contracts.Proto;
|
||||
// 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
|
||||
{
|
||||
private readonly GalaxyMxSession _session;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Lock _intervalLock = new();
|
||||
private int _lastAppliedIntervalMs = -1; // -1 = never applied; 0 = explicit "use gw default"
|
||||
|
||||
/// <summary>Initializes a new instance of GatewayGalaxySubscriber.</summary>
|
||||
/// <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));
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
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
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
/// <param name="itemHandles">The item handles to unsubscribe from.</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(
|
||||
"""{"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>
|
||||
/// 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
|
||||
|
||||
@@ -116,6 +116,39 @@ public sealed class FocasDriverMediumFindingsTests
|
||||
.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 ----
|
||||
|
||||
/// <summary>Verifies that GetHealth reflects state updated from concurrent reads.</summary>
|
||||
|
||||
+14
-11
@@ -1,14 +1,17 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
public sealed class GalaxyDriverApiKeyResolverTests
|
||||
{
|
||||
@@ -16,7 +19,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
[Fact]
|
||||
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>
|
||||
@@ -27,7 +30,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||
try
|
||||
{
|
||||
GalaxyDriver.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -43,7 +46,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxyDriver.ResolveApiKey($"env:{name}"));
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}"));
|
||||
ex.Message.ShouldContain(name);
|
||||
ex.Message.ShouldContain("unset");
|
||||
}
|
||||
@@ -56,7 +59,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
File.WriteAllText(path, " key-from-file \n");
|
||||
try
|
||||
{
|
||||
GalaxyDriver.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -70,7 +73,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxyDriver.ResolveApiKey($"file:{path}"));
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
||||
ex.Message.ShouldContain(path);
|
||||
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
|
||||
// operator who committed one by accident sees it at startup.
|
||||
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");
|
||||
logger.Entries.ShouldContain(e =>
|
||||
@@ -100,7 +103,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
// key (dev / parity rig). The resolver must accept it AND suppress the
|
||||
// warning so production logs aren't polluted on a deliberate dev choice.
|
||||
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");
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
@@ -115,7 +118,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
try
|
||||
{
|
||||
var logger = new CaptureLogger();
|
||||
GalaxyDriver.ResolveApiKey($"env:{name}", logger);
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
finally
|
||||
@@ -150,7 +153,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
try
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxyDriver.ResolveApiKey($"file:{path}"));
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
||||
ex.Message.ShouldContain("empty");
|
||||
}
|
||||
finally
|
||||
|
||||
+45
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user