Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty
8464e3f376 Phase 3 PR 33 — DriverHostStatus entity + EF migration (data-layer for LMX #7). New DriverHostStatus entity with composite key (NodeId, DriverInstanceId, HostName) persists each server node's per-host connectivity view — one row per (server node, driver instance, probe-reported host), which means a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6 rows because each server node owns its own runtime view of the shared host topology, not 3. Fields: NodeId (64), DriverInstanceId (64), HostName (256 — fits Galaxy FQDNs and Modbus host:port strings), State (DriverHostState enum — Unknown/Running/Stopped/Faulted, persisted as nvarchar(16) via HasConversion<string> so DBAs inspecting the table see readable state names not ordinals), StateChangedUtc + LastSeenUtc (datetime2(3) — StateChangedUtc tracks actual transitions while LastSeenUtc advances on every publisher heartbeat so the Admin UI can flag stale rows from a crashed Server independent of State), Detail (nullable 1024 — exception message from the driver's probe when Faulted, null otherwise).
DriverHostState enum lives in Configuration.Enums/ rather than reusing Core.Abstractions.HostState so the Configuration project stays free of driver-runtime dependencies (it's referenced by both the Admin process and the Server process, so pulling in the driver-abstractions assembly to every Admin build would be unnecessary weight). The server-side publisher hosted service (follow-up PR 34) will translate HostStatusChangedEventArgs.NewState to this enum on every transition.
No foreign key to ClusterNode — a Server may start reporting host status before its ClusterNode row exists (first-boot bootstrap), and we'd rather keep the status row than drop it. The Admin-side service that renders the dashboard will left-join on NodeId when presenting. Two indexes declared: IX_DriverHostStatus_Node drives the per-cluster drill-down (Admin UI joins ClusterNode on ClusterId to pick which NodeIds to fetch), IX_DriverHostStatus_LastSeen drives the stale-row query (now - LastSeen > threshold).
EF migration AddDriverHostStatus creates the table + PK + both indexes. Model snapshot updated. SchemaComplianceTests expected-tables list extended. DriverHostStatusTests (3 new cases, category SchemaCompliance, uses the shared fixture DB): composite key allows same (host, driver) across different nodes AND same (node, host) across different drivers — both real-world cases the publisher needs to support; upsert-in-place pattern (fetch-by-composite-PK, mutate, save) produces one row not two — the pattern the publisher will use; State enum persists as string not int — reading the DB via ADO.NET returns 'Faulted' not '3'.
Configuration.Tests SchemaCompliance suite: 10 pass / 0 fail (7 prior + 3 new). Configuration build clean. No Server or Admin code changes yet — publisher + /hosts page are PR 34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:38:41 -04:00
a9357600e7 Merge pull request 'Phase 3 PR 32 — Multi-driver integration test' (#31) from phase-3-pr32-multi-driver-integration into v2 2026-04-18 15:34:16 -04:00
Joseph Doherty
2f00c74bbb Phase 3 PR 32 — Multi-driver integration test. Closes LMX follow-up #6 with Server.Tests/MultipleDriverInstancesIntegrationTests.cs: registers two StubDriver instances (alpha + beta) with distinct DriverInstanceIds on one DriverHost, boots the full OpcUaApplicationHost, and exercises three behaviors end-to-end via a real OPC UA client session. (1) Each driver's namespace URI resolves to a distinct index in the client's NamespaceUris (alpha → urn:OtOpcUa:alpha, beta → urn:OtOpcUa:beta) — proves DriverNodeManager's namespaceUris-per-driver base-ctor wiring actually lands two separate INodeManager registrations. (2) Browsing one subtree returns only that driver's folder; the other driver's folder does NOT leak into the wrong subtree. This is the test that catches a cross-driver routing regression the v1 single-driver code path couldn't surface — if a future refactor flattens both drivers into a shared namespace, the 'shouldNotContain' assertion fails cleanly. (3) Reads route to the owning driver by namespace — alpha's ReadAsync returns 42 while beta's returns 99; a misroute would surface as 99 showing up on an alpha node id or vice versa. StubDriver is parameterized on (DriverInstanceId, folderName, readValue) so the same class constructs both instances without copy-paste.
No production code changes — pure additive test. Server.Tests Integration: 3 new tests pass; existing OpcUaServerIntegrationTests stays green (single-driver case still exercised there). Full Server.Tests Unit still 43 / 0. Deferred: multi-driver alarm-event case (two drivers each raising a GalaxyAlarmEvent, assert each condition lands on its owning instance's condition node) — needs a stub IAlarmSource and is worth its own focused PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:29:49 -04:00
5d5e1f9650 Merge pull request 'Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility' (#30) from phase-3-pr31-live-ldap-ad-compat into v2 2026-04-18 15:27:54 -04:00
Joseph Doherty
4886a5783f Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility. Closes LMX follow-up #4 with 6 live-bind tests in Server.Tests/LdapUserAuthenticatorLiveTests.cs against the dev GLAuth instance at localhost:3893 (skipped cleanly when unreachable via Assert.Skip + a clear SkipReason — matches the GalaxyRepositoryLiveSmokeTests pattern). Coverage: valid credentials bind + surface DisplayName; wrong password fails; unknown user fails; empty credentials fail pre-flight without touching the directory; writeop user's memberOf maps through GroupToRole to WriteOperate (the exact string WriteAuthzPolicy.IsAllowed expects); admin user surfaces all four mapped roles (WriteOperate + WriteTune + WriteConfigure + AlarmAck) proving memberOf parsing doesn't stop after the first match. While wiring this up, the authenticator's hard-coded user-lookup filter 'uid=<name>' didn't match GLAuth (which keys users by cn and doesn't populate uid) — AND it doesn't match Active Directory either, which uses sAMAccountName. Added UserNameAttribute to LdapOptions (default 'uid' for RFC 2307 backcompat) so deployments override to 'cn' / 'sAMAccountName' / 'userPrincipalName' as the directory requires; authenticator filter now interpolates the configured attribute. The default stays 'uid' so existing test fixtures and OpenLDAP installs keep working without a config change — a regression guard in LdapUserAuthenticatorAdCompatTests.LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat pins this so a future 'helpful' default change can't silently break anyone.
Active Directory compatibility. LdapOptions xml-doc expanded with a cheat-sheet covering Server (DC FQDN), Port 389 vs 636, UseTls=true under AD LDAP-signing enforcement, dedicated read-only service account DN, sAMAccountName vs userPrincipalName vs cn trade-offs, memberOf DN shape (CN=Group,OU=...,DC=... with the CN= RDN stripped to become the GroupToRole key), and the explicit 'nested groups NOT expanded' call-out (LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups is a future authenticator enhancement, not a config change). docs/security.md §'Active Directory configuration' adds a complete appsettings.json snippet with realistic AD group names (OPCUA-Operators → WriteOperate, OPCUA-Engineers → WriteConfigure, OPCUA-AlarmAck → AlarmAck, OPCUA-Tuners → WriteTune), LDAPS port 636, TLS on, insecure-LDAP off, and operator-facing notes on each field. LdapUserAuthenticatorAdCompatTests (5 unit guards): ExtractFirstRdnValue parses AD-style 'CN=OPCUA-Operators,OU=...,DC=...' DNs correctly (case-preserving — operators' GroupToRole keys stay readable); also handles mixed case and spaces in group names ('Domain Users'); also works against the OpenLDAP ou=<group>,ou=groups shape (GLAuth) so one extractor tolerates both memberOf formats common in the field; EscapeLdapFilter escapes the RFC 4515 injection set (\, *, (, ), \0) so a malicious login like 'admin)(cn=*' can't break out of the filter; default UserNameAttribute regression guard.
Test posture — Server.Tests Unit: 43 pass / 0 fail (38 prior + 5 new AD-compat guards). Server.Tests LiveLdap category: 6 pass / 0 fail against running GLAuth (would skip cleanly without). Server build clean, 0 errors, 0 warnings.
Deferred: the session-identity end-to-end check (drive a full OPC UA UserName session, then read a 'whoami' node to verify the role landed on RoleBasedIdentity). That needs a test-only address-space node and is scoped for a separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:23:22 -04:00
d70a2e0077 Merge pull request 'Phase 3 PR 30 — Modbus integration-test project scaffold + DL205 smoke test' (#29) from phase-3-pr30-modbus-integration-scaffold into v2 2026-04-18 15:08:45 -04:00
15 changed files with 2102 additions and 25 deletions

View File

@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference. Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
### Active Directory configuration
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
```json
{
"OpcUaServer": {
"Ldap": {
"Enabled": true,
"Server": "dc01.corp.example.com",
"Port": 636,
"UseTls": true,
"AllowInsecureLdap": false,
"SearchBase": "DC=corp,DC=example,DC=com",
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
"ServiceAccountPassword": "<from your secret store>",
"DisplayNameAttribute": "displayName",
"GroupAttribute": "memberOf",
"UserNameAttribute": "sAMAccountName",
"GroupToRole": {
"OPCUA-Operators": "WriteOperate",
"OPCUA-Engineers": "WriteConfigure",
"OPCUA-AlarmAck": "AlarmAck",
"OPCUA-Tuners": "WriteTune"
}
}
}
}
```
Notes:
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
### Security Considerations ### Security Considerations
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments. - LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.

View File

@@ -58,18 +58,25 @@ Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
deployment default. That's a production-hardening config change, not a code deployment default. That's a production-hardening config change, not a code
gap — the Admin UI is now ready to be the trust gate. gap — the Admin UI is now ready to be the trust gate.
## 4. Live-LDAP integration test ## 4. Live-LDAP integration test — **DONE (PR 31)**
**Status**: PR 19 unit-tested the auth-flow shape; the live bind path is PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
exercised only by the pre-existing `Admin.Tests/LdapLiveBindTests.cs` which tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
uses the same Novell library against a running GLAuth at `localhost:3893`. when the port is unreachable. Covers: valid bind, wrong password, unknown
user, empty credentials, single-group → WriteOperate mapping, multi-group
admin user surfacing all mapped roles.
**To do**: Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
- Add `OpcUaServerIntegrationTests.Valid_username_authenticates_against_live_ldap` compat) so Active Directory deployments can configure `sAMAccountName` /
with the same skip-when-unreachable guard. `userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
- Assert `session.Identity` on the server side carries the expected role (5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
after bind — requires exposing a test hook or reading identity from a `docs/security.md` §"Active Directory configuration" for the AD appsettings
new `IHostConnectivityProbe`-style "whoami" variable in the address space. snippet.
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
drive a full OPC UA session with username/password, then read an
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
That needs a test-only address-space node and is a separate PR.
## 5. Full Galaxy live-service smoke test against the merged v2 stack ## 5. Full Galaxy live-service smoke test against the merged v2 stack
@@ -84,18 +91,22 @@ no single end-to-end smoke test.
subscribes to one of its attributes, writes a value back, and asserts the subscribes to one of its attributes, writes a value back, and asserts the
write round-tripped through MXAccess. Skip when ArchestrA isn't running. write round-tripped through MXAccess. Skip when ArchestrA isn't running.
## 6. Second driver instance on the same server ## 6. Second driver instance on the same server — **DONE (PR 32)**
**Status**: `DriverHost.RegisterAsync` supports multiple drivers; the OPC UA `Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
server creates one `DriverNodeManager` per driver and isolates their drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
subtrees under distinct namespace URIs. Not proven with two active full OPC UA server, and asserts three behaviors: (1) each driver's namespace
`GalaxyProxyDriver` instances pointing at different Galaxies. URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
NamespaceUris, (2) browsing one subtree returns that driver's folder and
does NOT leak the other driver's folder, (3) reads route to the correct
driver — the alpha instance returns 42 while beta returns 99, so a misroute
would surface at the assertion layer.
**To do**: Deferred: the alarm-event multi-driver parity case (two drivers each raising
- Integration test that registers two driver instances, each with a distinct a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
`DriverInstanceId` + endpoint in its own session, asserts nodes from both condition node). Alarm tracking already has its own integration test
appear under the correct subtrees, alarm events land on the correct (`AlarmSubscription*`); the multi-driver alarm case would need a stub
instance's condition nodes. `IAlarmSource` that's worth its own focused PR.
## 7. Host-status per-AppEngine granularity → Admin UI dashboard ## 7. Host-status per-AppEngine granularity → Admin UI dashboard

View File

@@ -0,0 +1,61 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per-host connectivity snapshot the Server publishes for each driver's
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
/// rows, not 3, because each server node owns its own runtime view.
/// </summary>
/// <remarks>
/// <para>
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
/// drill-down). The publisher hosted service on the Server side subscribes to every
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
/// </para>
/// <para>
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
/// rather keep the status row than drop it. The Admin-side service left-joins on
/// NodeId when presenting rows.
/// </para>
/// </remarks>
public sealed class DriverHostStatus
{
/// <summary>Server node that's running the driver.</summary>
public required string NodeId { get; set; }
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
/// a display string.
/// </summary>
public required string HostName { get; set; }
public DriverHostState State { get; set; } = DriverHostState.Unknown;
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
public DateTime StateChangedUtc { get; set; }
/// <summary>
/// Advances on every publisher heartbeat — the Admin UI uses
/// <c>now - LastSeenUtc &gt; threshold</c> to flag rows whose owning Server has
/// stopped reporting (crashed, network-partitioned, etc.), independent of
/// <see cref="State"/>.
/// </summary>
public DateTime LastSeenUtc { get; set; }
/// <summary>
/// Optional human-readable detail populated when <see cref="State"/> is
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
/// driver's probe. Null for Running / Stopped / Unknown transitions.
/// </summary>
public string? Detail { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
/// Configuration project stays free of driver-runtime dependencies.
/// </summary>
/// <remarks>
/// The server-side publisher (follow-up PR) translates
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
/// </remarks>
public enum DriverHostState
{
Unknown,
Running,
Stopped,
Faulted,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverHostStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverHostStatus",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_LastSeen",
table: "DriverHostStatus",
column: "LastSeenUtc");
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_Node",
table: "DriverHostStatus",
column: "NodeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverHostStatus");
}
}
}

View File

@@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
}); });
}); });
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
{
b.Property<string>("NodeId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HostName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Detail")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<DateTime>("LastSeenUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<DateTime>("StateChangedUtc")
.HasColumnType("datetime2(3)");
b.HasKey("NodeId", "DriverInstanceId", "HostName");
b.HasIndex("LastSeenUtc")
.HasDatabaseName("IX_DriverHostStatus_LastSeen");
b.HasIndex("NodeId")
.HasDatabaseName("IX_DriverHostStatus_Node");
b.ToTable("DriverHostStatus", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
{ {
b.Property<Guid>("DriverInstanceRowId") b.Property<Guid>("DriverInstanceRowId")

View File

@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>(); public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>(); public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>(); public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureClusterNodeGenerationState(modelBuilder); ConfigureClusterNodeGenerationState(modelBuilder);
ConfigureConfigAuditLog(modelBuilder); ConfigureConfigAuditLog(modelBuilder);
ConfigureExternalIdReservation(modelBuilder); ConfigureExternalIdReservation(modelBuilder);
ConfigureDriverHostStatus(modelBuilder);
} }
private static void ConfigureServerCluster(ModelBuilder modelBuilder) private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -484,4 +486,30 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment"); e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
}); });
} }
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverHostStatus>(e =>
{
e.ToTable("DriverHostStatus");
// Composite key — one row per (server node, driver instance, probe-reported host).
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
// 6 rows because each server node owns its own runtime view; the composite key is
// what lets both views coexist without shadowing each other.
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.HostName).HasMaxLength(256);
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
e.Property(x => x.Detail).HasMaxLength(1024);
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
});
}
} }

View File

@@ -2,11 +2,37 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary> /// <summary>
/// LDAP settings for the OPC UA server's UserName token validator. Bound from /// LDAP settings for the OPC UA server's UserName token validator. Bound from
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults match the GLAuth dev instance /// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults target the GLAuth dev instance
/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set <see cref="UseTls"/> /// (localhost:3893, <c>dc=lmxopcua,dc=local</c>) for the stock inner-loop setup. Production
/// true, populate <see cref="ServiceAccountDn"/> for search-then-bind, and maintain /// deployments are expected to point at Active Directory; see <see cref="UserNameAttribute"/>
/// <see cref="GroupToRole"/> with the real LDAP group names. /// and the per-field xml-docs for the AD-specific overrides.
/// </summary> /// </summary>
/// <remarks>
/// <para><b>Active Directory cheat-sheet</b>:</para>
/// <list type="bullet">
/// <item><see cref="Server"/>: one of the domain controllers, or the domain FQDN (will round-robin DCs).</item>
/// <item><see cref="Port"/>: <c>389</c> (LDAP) or <c>636</c> (LDAPS); use 636 + <see cref="UseTls"/> in production.</item>
/// <item><see cref="UseTls"/>: <c>true</c>. AD increasingly rejects plain-LDAP bind under LDAP-signing enforcement.</item>
/// <item><see cref="AllowInsecureLdap"/>: <c>false</c>. Dev escape hatch only.</item>
/// <item><see cref="SearchBase"/>: <c>DC=corp,DC=example,DC=com</c> — your domain's base DN.</item>
/// <item><see cref="ServiceAccountDn"/>: a dedicated service principal with read access to user + group entries
/// (e.g. <c>CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com</c>). Never a privileged admin.</item>
/// <item><see cref="UserNameAttribute"/>: <c>sAMAccountName</c> (classic login name) or <c>userPrincipalName</c>
/// (user@domain form). Default is <c>uid</c> which AD does <b>not</b> populate, so this override is required.</item>
/// <item><see cref="DisplayNameAttribute"/>: <c>displayName</c> gives the human name; <c>cn</c> works too but is less rich.</item>
/// <item><see cref="GroupAttribute"/>: <c>memberOf</c> — matches AD's default. Values are full DNs
/// (<c>CN=&lt;Group&gt;,OU=...,DC=...</c>); the authenticator strips the leading <c>CN=</c> RDN value and uses
/// that as the lookup key in <see cref="GroupToRole"/>.</item>
/// <item><see cref="GroupToRole"/>: maps your AD group common-names to OPC UA roles — e.g.
/// <c>{"OPCUA-Operators" : "WriteOperate", "OPCUA-Engineers" : "WriteConfigure"}</c>.</item>
/// </list>
/// <para>
/// Nested groups are <b>not</b> expanded — AD's <c>tokenGroups</c> / <c>LDAP_MATCHING_RULE_IN_CHAIN</c>
/// membership-chain filter isn't used. Assign users directly to the role-mapped groups, or pre-flatten
/// membership in your directory. If nested expansion becomes a requirement, it's an authenticator
/// enhancement (not a config change).
/// </para>
/// </remarks>
public sealed class LdapOptions public sealed class LdapOptions
{ {
public bool Enabled { get; init; } = false; public bool Enabled { get; init; } = false;
@@ -23,6 +49,20 @@ public sealed class LdapOptions
public string DisplayNameAttribute { get; init; } = "cn"; public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf"; public string GroupAttribute { get; init; } = "memberOf";
/// <summary>
/// LDAP attribute used to match a login name against user entries in the directory.
/// Defaults to <c>uid</c> (RFC 2307). Common overrides:
/// <list type="bullet">
/// <item><c>sAMAccountName</c> — Active Directory, classic NT-style login names (e.g. <c>jdoe</c>).</item>
/// <item><c>userPrincipalName</c> — Active Directory, email-style (e.g. <c>jdoe@corp.example.com</c>).</item>
/// <item><c>cn</c> — GLAuth + some OpenLDAP deployments where users are keyed by common-name.</item>
/// </list>
/// Used only when <see cref="ServiceAccountDn"/> is non-empty (search-then-bind path) —
/// direct-bind fallback constructs the DN as <c>cn=&lt;name&gt;,&lt;SearchBase&gt;</c>
/// regardless of this setting and is not a production-grade path against AD.
/// </summary>
public string UserNameAttribute { get; init; } = "uid";
/// <summary> /// <summary>
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group /// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse /// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse

View File

@@ -106,7 +106,7 @@ public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserA
{ {
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct); await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
var filter = $"(uid={EscapeLdapFilter(username)})"; var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
var results = await Task.Run(() => var results = await Task.Run(() =>
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct); conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);

View File

@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
{
[Fact]
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
{
await using var ctx = NewContext();
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
// redundancy case. Both rows must be insertable because each server node owns its own
// runtime view of the shared host.
var now = DateTime.UtcNow;
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
State = DriverHostState.Running,
StateChangedUtc = now, LastSeenUtc = now,
});
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
State = DriverHostState.Stopped,
StateChangedUtc = now, LastSeenUtc = now,
Detail = "secondary hasn't taken over yet",
});
// Same server node + host, different driver instance — second driver doesn't clobber.
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
State = DriverHostState.Running,
StateChangedUtc = now, LastSeenUtc = now,
});
await ctx.SaveChangesAsync();
var rows = await ctx.DriverHostStatuses.AsNoTracking()
.Where(r => r.HostName == "GRPlatform").ToListAsync();
rows.Count.ShouldBe(3);
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
}
[Fact]
public async Task Upsert_pattern_for_same_key_updates_in_place()
{
// The publisher hosted service (follow-up PR) upserts on every transition +
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
// keyed on the composite PK. If the composite key ever changes, this test breaks
// loudly so the publisher gets a synchronized update.
await using var ctx = NewContext();
var t0 = DateTime.UtcNow;
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
State = DriverHostState.Running,
StateChangedUtc = t0, LastSeenUtc = t0,
});
await ctx.SaveChangesAsync();
var t1 = t0.AddSeconds(30);
await using (var ctx2 = NewContext())
{
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
existing.State = DriverHostState.Faulted;
existing.StateChangedUtc = t1;
existing.LastSeenUtc = t1;
existing.Detail = "transport reset by peer";
await ctx2.SaveChangesAsync();
}
await using var ctx3 = NewContext();
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
final.State.ShouldBe(DriverHostState.Faulted);
final.Detail.ShouldBe("transport reset by peer");
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
}
[Fact]
public async Task Enum_persists_as_string_not_int()
{
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
// string back via ADO; if someone drops the conversion the column will contain '1'
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
// directly should see readable state names, not enum ordinals.
await using var ctx = NewContext();
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
State = DriverHostState.Faulted,
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
await using var conn = fixture.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
var rawValue = (string?)await cmd.ExecuteScalarAsync();
rawValue.ShouldBe("Faulted");
}
private OtOpcUaConfigDbContext NewContext()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(fixture.ConnectionString)
.Options;
return new OtOpcUaConfigDbContext(options);
}
}

View File

@@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests
"Namespace", "UnsArea", "UnsLine", "Namespace", "UnsArea", "UnsLine",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
"NodeAcl", "ExternalIdReservation", "NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
}; };
var actual = QueryStrings(@" var actual = QueryStrings(@"

View File

@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Deterministic guards for Active Directory compatibility of the internal helpers
/// <see cref="LdapUserAuthenticator"/> relies on. We can't live-bind against AD in unit
/// tests — instead, we pin the behaviors AD depends on (DN-parsing of AD-style
/// <c>memberOf</c> values, filter escaping with case-preserving RDN extraction) so a
/// future refactor can't silently break the AD path while the GLAuth live-smoke stays
/// green.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LdapUserAuthenticatorAdCompatTests
{
[Fact]
public void ExtractFirstRdnValue_parses_AD_memberOf_group_name_from_CN_dn()
{
// AD's memberOf values use uppercase CN=… and full domain paths. The extractor
// returns the first RDN's value regardless of attribute-type case, so operators'
// GroupToRole keys stay readable ("OPCUA-Operators" not "CN=OPCUA-Operators,...").
var dn = "CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("OPCUA-Operators");
}
[Fact]
public void ExtractFirstRdnValue_handles_mixed_case_and_spaces_in_group_name()
{
var dn = "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("Domain Users");
}
[Fact]
public void ExtractFirstRdnValue_also_works_for_OpenLDAP_ou_style_memberOf()
{
// GLAuth + some OpenLDAP deployments expose memberOf as ou=<group>,ou=groups,...
// The authenticator needs one extractor that tolerates both shapes since directories
// in the field mix them depending on schema.
var dn = "ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("WriteOperate");
}
[Fact]
public void EscapeLdapFilter_prevents_injection_via_samaccountname_lookup()
{
// AD login names can contain characters that are meaningful to LDAP filter syntax
// (parens, backslashes). The authenticator builds filters as
// ($"({UserNameAttribute}={EscapeLdapFilter(username)})") so injection attempts must
// not break out of the filter. The RFC 4515 escape set is: \ → \5c, * → \2a, ( → \28,
// ) → \29, \0 → \00.
LdapUserAuthenticator.EscapeLdapFilter("admin)(cn=*")
.ShouldBe("admin\\29\\28cn=\\2a");
LdapUserAuthenticator.EscapeLdapFilter("domain\\user")
.ShouldBe("domain\\5cuser");
}
[Fact]
public void LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat()
{
// Regression guard: PR 31 introduced UserNameAttribute with a default of "uid" so
// existing deployments (pre-AD config) keep working. Changing the default breaks
// everyone's config silently; require an explicit review.
new LdapOptions().UserNameAttribute.ShouldBe("uid");
}
}

View File

@@ -0,0 +1,154 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
/// when the port is unreachable so the test suite stays portable on boxes without a
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
/// not just the flow-shape unit tests from PR 19.
/// </summary>
/// <remarks>
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
/// Server authenticator has to work even when the Server process is on a machine that
/// doesn't have the Admin assemblies loaded, and the two share no code by design
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
/// construction, DN resolution, or memberOf parsing, these tests surface it.
/// </remarks>
[Trait("Category", "LiveLdap")]
public sealed class LdapUserAuthenticatorLiveTests
{
private const string GlauthHost = "localhost";
private const int GlauthPort = 3893;
private static bool GlauthReachable()
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync(GlauthHost, GlauthPort);
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
}
catch { return false; }
}
// GLAuth dev directory groups are named identically to the OPC UA roles
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
// identity translation. The authenticator still exercises every step of the pipeline —
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
// data; the identity map just means the assertion is phrased with no surprise rename
// in the middle.
private static LdapOptions GlauthOptions() => new()
{
Enabled = true,
Server = GlauthHost,
Port = GlauthPort,
UseTls = false,
AllowInsecureLdap = true,
SearchBase = "dc=lmxopcua,dc=local",
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
// user's password, then stays on the service-account session for memberOf lookup.
// Without this path, GLAuth ACLs block the authenticated user from reading their
// own entry in full — a plain self-search returns zero results and the role list
// ends up empty.
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
ServiceAccountPassword = "serviceaccount123",
DisplayNameAttribute = "cn",
GroupAttribute = "memberOf",
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ReadOnly",
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
["AlarmAck"] = "AlarmAck",
},
};
private static LdapUserAuthenticator NewAuthenticator() =>
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
[Fact]
public async Task Valid_credentials_bind_and_return_success()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.DisplayName.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
{
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
// (WriteOperate is the exact string the policy checks for), so the failure mode is
// concrete, not abstract.
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
}
[Fact]
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
// surface every mapped role, not just the primary group. Guards against a regression
// where the memberOf parsing stops after the first match or misses the primary-group
// fallback.
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
result.Roles.ShouldContain("AlarmAck");
}
[Fact]
public async Task Wrong_password_returns_failure()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Unknown_user_returns_failure()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task Empty_credentials_fail_without_touching_the_directory()
{
// Pre-flight guard — doesn't require GLAuth.
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Error.ShouldContain("Credentials", Case.Insensitive);
}
}

View File

@@ -0,0 +1,191 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Closes LMX follow-up #6 — proves that two <see cref="IDriver"/> instances registered
/// on the same <see cref="DriverHost"/> land in isolated namespaces and their reads
/// route to the correct driver. The existing <see cref="OpcUaServerIntegrationTests"/>
/// only exercises a single-driver topology; this sibling fixture registers two.
/// </summary>
/// <remarks>
/// Each driver gets its own namespace URI of the form <c>urn:OtOpcUa:{DriverInstanceId}</c>
/// (per <c>DriverNodeManager</c>'s base-class <c>namespaceUris</c> argument). A client
/// that browses one namespace must see only that driver's subtree, and a read against a
/// variable in one namespace must return that driver's value, not the other's — this is
/// what stops a cross-driver routing regression from going unnoticed when the v1
/// single-driver code path gets new knobs.
/// </remarks>
[Trait("Category", "Integration")]
public sealed class MultipleDriverInstancesIntegrationTests : IAsyncLifetime
{
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaMultiDriverTest";
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multi-{Guid.NewGuid():N}");
private DriverHost _driverHost = null!;
private OpcUaApplicationHost _server = null!;
public async ValueTask InitializeAsync()
{
_driverHost = new DriverHost();
await _driverHost.RegisterAsync(new StubDriver("alpha", folderName: "AlphaFolder", readValue: 42),
"{}", CancellationToken.None);
await _driverHost.RegisterAsync(new StubDriver("beta", folderName: "BetaFolder", readValue: 99),
"{}", CancellationToken.None);
var options = new OpcUaServerOptions
{
EndpointUrl = _endpoint,
ApplicationName = "OtOpcUaMultiDriverTest",
ApplicationUri = "urn:OtOpcUa:Server:MultiDriverTest",
PkiStoreRoot = _pkiRoot,
AutoAcceptUntrustedClientCertificates = true,
};
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
await _server.StartAsync(CancellationToken.None);
}
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync();
await _driverHost.DisposeAsync();
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
}
[Fact]
public async Task Both_drivers_register_under_their_own_urn_namespace()
{
using var session = await OpenSessionAsync();
var alphaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
var betaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
alphaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'alpha' must register its namespace URI");
betaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'beta' must register its namespace URI");
alphaNs.ShouldNotBe(betaNs, "each driver owns its own namespace");
}
[Fact]
public async Task Each_driver_subtree_exposes_only_its_own_folder()
{
using var session = await OpenSessionAsync();
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
var alphaRoot = new NodeId("alpha", alphaNs);
session.Browse(null, null, alphaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var alphaRefs);
alphaRefs.ShouldContain(r => r.BrowseName.Name == "AlphaFolder",
"alpha's subtree must contain alpha's folder");
alphaRefs.ShouldNotContain(r => r.BrowseName.Name == "BetaFolder",
"alpha's subtree must NOT see beta's folder — cross-driver leak would hide subscription-routing bugs");
var betaRoot = new NodeId("beta", betaNs);
session.Browse(null, null, betaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var betaRefs);
betaRefs.ShouldContain(r => r.BrowseName.Name == "BetaFolder");
betaRefs.ShouldNotContain(r => r.BrowseName.Name == "AlphaFolder");
}
[Fact]
public async Task Reads_route_to_the_correct_driver_by_namespace()
{
using var session = await OpenSessionAsync();
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
var alphaValue = session.ReadValue(new NodeId("AlphaFolder.Var1", alphaNs));
var betaValue = session.ReadValue(new NodeId("BetaFolder.Var1", betaNs));
alphaValue.Value.ShouldBe(42, "alpha driver's ReadAsync returns 42 — a misroute would surface as 99");
betaValue.Value.ShouldBe(99, "beta driver's ReadAsync returns 99 — a misroute would surface as 42");
}
private async Task<ISession> OpenSessionAsync()
{
var cfg = new ApplicationConfiguration
{
ApplicationName = "OtOpcUaMultiDriverTestClient",
ApplicationUri = "urn:OtOpcUa:MultiDriverTestClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_pkiRoot, "client-own"),
SubjectName = "CN=OtOpcUaMultiDriverTestClient",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
};
await cfg.Validate(ApplicationType.Client);
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
var endpointConfig = EndpointConfiguration.Create(cfg);
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaMultiDriverTestClientSession", 60000,
new UserIdentity(new AnonymousIdentityToken()), null);
}
/// <summary>
/// Driver stub that returns a caller-specified folder + variable + read value so two
/// instances in the same server can be told apart at the assertion layer.
/// </summary>
private sealed class StubDriver(string driverInstanceId, string folderName, int readValue)
: IDriver, ITagDiscovery, IReadable
{
public string DriverInstanceId => driverInstanceId;
public string DriverType => "Stub";
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
var folder = builder.Folder(folderName, folderName);
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
$"{folderName}.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
return Task.CompletedTask;
}
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> result =
fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray();
return Task.FromResult(result);
}
}
}