E2E — reverse-write stage returns 0x801F0000 for anonymous session on Modbus HR[200] #219

Closed
opened 2026-04-21 11:33:20 -04:00 by dohertj2 · 1 comment
Owner

Surfaced by the #209 exit-gate smoke run (PR #218). 4 of 5 e2e stages PASS end-to-end against a live server + seed-modbus-smoke.sql + pymodbus fixture:

=== Modbus e2e summary: 4/5 passed ===
[PASS] Probe
[PASS] Driver loopback
[PASS] Server bridge   (driver → server → client)
[FAIL] OPC UA write bridge   status 0x801F0000
[PASS] Subscribe sees change

The server log shows no exception on the write path — the status is coming from the OPC UA stack's guard chain, not from ModbusDriver.WriteAsync.

DriverNodeManager.OnWriteValue (src/.../Server/OpcUa/DriverNodeManager.cs:497) walks:

  1. Virtual-tag/alarm source check — not our case
  2. _writable is null check — shouldn't fire (ModbusDriver implements IWritable)
  3. WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, roles)anonymous session has no roles, so this returns BadUserAccessDenied (0x801F0000). Matches the wire status.

Likely cause: the e2e script's otopcua-cli write connects anonymously; WriteAuthzPolicy defaults deny Operate for an empty role set. SecurityClassification for HR[200] is Operate (via ModbusDriver.DiscoverAsync line 122 — t.Writable ? Operate : ViewOnly).

Fix options (pick one)

  1. Seed an anonymous role grant for the smoke cluster. Insert a LdapGroupRoleMapping or equivalent that maps unauthenticated sessions → WriteOperate in the smoke cluster only. Cleanest + matches Phase 6.2 conventions.
  2. Update the e2e script to supply credentials. otopcua-cli write -u <url> -U <user> -P <pass> exists — seed a test user with WriteOperate and pass them. Downside: credentials in the e2e sidecar.
  3. Server-side: WriteAuthzPolicy should accept Operate for anonymous sessions in a dev-mode flag. Biggest scope; probably wrong long-term.

Reproduce

  1. Apply seed-modbus-smoke.sql
  2. docker compose -f tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d
  3. Boot server with Node__NodeId=modbus-smoke-node Node__ClusterId=modbus-smoke + sa creds
  4. ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"
  5. Observe stages 1-3 + 5 PASS, stage 4 FAIL with Write failed: 0x801F0000
Surfaced by the #209 exit-gate smoke run (PR #218). 4 of 5 e2e stages PASS end-to-end against a live server + `seed-modbus-smoke.sql` + pymodbus fixture: ``` === Modbus e2e summary: 4/5 passed === [PASS] Probe [PASS] Driver loopback [PASS] Server bridge (driver → server → client) [FAIL] OPC UA write bridge status 0x801F0000 [PASS] Subscribe sees change ``` The server log shows no exception on the write path — the status is coming from the OPC UA stack's guard chain, not from `ModbusDriver.WriteAsync`. `DriverNodeManager.OnWriteValue` (`src/.../Server/OpcUa/DriverNodeManager.cs:497`) walks: 1. Virtual-tag/alarm source check — not our case 2. `_writable is null` check — shouldn't fire (`ModbusDriver` implements `IWritable`) 3. `WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, roles)` — **anonymous session has no roles**, so this returns `BadUserAccessDenied` (`0x801F0000`). Matches the wire status. Likely cause: the e2e script's `otopcua-cli write` connects anonymously; `WriteAuthzPolicy` defaults deny `Operate` for an empty role set. `SecurityClassification` for HR[200] is `Operate` (via `ModbusDriver.DiscoverAsync` line 122 — `t.Writable ? Operate : ViewOnly`). ## Fix options (pick one) 1. **Seed an anonymous role grant for the smoke cluster.** Insert a `LdapGroupRoleMapping` or equivalent that maps unauthenticated sessions → `WriteOperate` in the smoke cluster only. Cleanest + matches Phase 6.2 conventions. 2. **Update the e2e script to supply credentials.** `otopcua-cli write -u <url> -U <user> -P <pass>` exists — seed a test user with `WriteOperate` and pass them. Downside: credentials in the e2e sidecar. 3. **Server-side: `WriteAuthzPolicy` should accept `Operate` for anonymous sessions in a dev-mode flag.** Biggest scope; probably wrong long-term. ## Reproduce 1. Apply `seed-modbus-smoke.sql` 2. `docker compose -f tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d` 3. Boot server with `Node__NodeId=modbus-smoke-node Node__ClusterId=modbus-smoke` + sa creds 4. `./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"` 5. Observe stages 1-3 + 5 PASS, stage 4 FAIL with `Write failed: 0x801F0000`
Author
Owner

Fixed in PR #221. Modbus e2e now 5/5 PASS with OpcUaServer:AnonymousRoles=["WriteOperate"].

Fixed in PR #221. Modbus e2e now 5/5 PASS with `OpcUaServer:AnonymousRoles=["WriteOperate"]`.
dohertj2 referenced this issue from a commit 2026-04-30 08:21:26 -04:00
OpcUaClient integration fixture — opc-plc in Docker closes the wire-level gap (#215). Closes task #215. The OpcUaClient driver had the richest capability matrix in the fleet (reads/writes/subscribe/alarms/history across 11 unit-test classes) + zero wire-level coverage; every test mocked the Session surface. opc-plc is Microsoft Industrial IoT's OPC UA PLC simulator — already containerized, already on MCR, pinned to 2.14.10 here. Wins vs the loopback-against-our-own-server option we'd originally scoped: (a) independent cert chain + user-token handling catches interop bugs loopback can't because both endpoints would share our own cert store; (b) pinned image tag fixes the test surface in a way our evolving server wouldn't; (c) the --alm flag opens the door to real IAlarmSource coverage later without building a custom FakeAlarmDriver. Loss vs loopback: both use the OPCFoundation.NetStandard stack internally so bugs common to that stack don't surface — addressed by a follow-up to add open62541/open62541 as a second independent-stack image (tracked). Docker is the fixture launcher — no PowerShell/Python wrapper like Modbus/pymodbus or S7/python-snap7 because opc-plc ships containerized. Docker/docker-compose.yml pins 2.14.10 + maps port 50000 + command flags --pn=50000 --ut --aa --alm; the healthcheck TCP-probes 50000 so `docker ps` surfaces ready state. Fixture OpcPlcFixture follows the same shape as Snap7ServerFixture + ModbusSimulatorFixture: collection-scoped, parses OPCUA_SIM_ENDPOINT (default opc.tcp://localhost:50000) into host + port, 2-second TCP probe at init, `SkipReason` records the failure for Assert.Skip. Forced IPv4 on the probe socket for the same reason those two fixtures do — .NET's dual-stack "localhost" resolves IPv6 ::1 first + hangs the full connect timeout when the target binds 0.0.0.0 (IPv4). OpcPlcProfile holds well-known node identifiers opc-plc exposes (`ns=3;s=StepUp`, `FastUInt1`, `RandomSignedInt32`, `AlternatingBoolean`) + builds `OpcUaClientDriverOptions` with SecurityPolicy.None + AutoAcceptCertificates=true since opc-plc regenerates its server cert on every container spin-up + there's no meaningful chain to validate against in CI. Three smoke tests covering what the unit suite couldn't reach: (1) Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack — full Secure Channel + Session + Read on `ns=3;s=StepUp` (counter that ticks every 1 s); (2) Client_reads_batch_of_varied_types_from_live_simulator — batch Read of UInt32 / Int32 / Boolean to prove typed Variant decoding, with an explicit ShouldBeOfType<bool> assertion on AlternatingBoolean to catch the common "variant gets stringified" regression; (3) Client_subscribe_receives_StepUp_data_changes_from_live_server — real MonitoredItem subscription on FastUInt1 (100 ms cadence) with a SemaphoreSlim gate + 3 s deadline on the first OnDataChange fire, tolerating container warm-up. Driver ran end-to-end against a live 2.14.10 container: all 3 pass; unit suite 78/78 unchanged. Container lifecycle verified (compose up → tests → compose down) clean, no leaked state. Docker/README.md documents install (Docker Desktop already on the dev box per Phase 1 decision #134), run (compose up / compose up -d / compose down), endpoint override (OPCUA_SIM_ENDPOINT), what opc-plc advertises with the current command flags, what's tunable via compose-file tweaks (--daa for username auth tests; --fn/--fr/--ft for subscription-stress nodes), known limitation that opc-plc shares the OPCFoundation stack with our driver. OpcUaClient-Test-Fixture.md updated — TL;DR flipped from "there is no integration fixture" to the new reality; "What it actually covers" gains an Integration section listing the three smoke tests. Follow-up the doc flags: add open62541/open62541 as a second image for fully-independent-stack interop coverage; once #219 (server-side IAlarmSource/IHistoryProvider integration tests) lands, re-run the client-side suite against opc-plc's --alm nodes to close the alarm gap from the client side too.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#219