Compare commits

...

84 Commits

Author SHA1 Message Date
Joseph Doherty 967da5b43b feat(theme): upstream router controls and responsive shell fixes 2026-11-04 00:52:25 -05:00
Joseph Doherty 6ad15fcf93 Index: record mxaccessgw operator-authenticated alarm ack (proto change)
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 08:52:46 -04:00
Joseph Doherty 795af1da71 Record merged-main verification and remaining issue gates 2026-10-13 22:10:40 -04:00
Joseph Doherty 65c6b99cf2 Record verified driver fixture cleanup merge 2026-10-13 21:08:01 -04:00
Joseph Doherty 420c2e0783 Record verified freshness merge and pending cleanup follow-ups 2026-10-13 19:04:46 -04:00
Joseph Doherty 3cacdca861 Record verified ServiceLevel fixes and freshness follow-up 2026-10-13 16:00:45 -04:00
Joseph Doherty ade49c0935 Record verified OPC UA CI closures and pending ServiceLevel work 2026-10-13 13:58:03 -04:00
Joseph Doherty 02480d4fbc docs: record verified OPC UA CI and S7 evidence corrections 2026-10-13 11:57:35 -04:00
Joseph Doherty 87bb647efb Record verified keepalive survival test fix 2026-10-13 11:02:00 -04:00
Joseph Doherty a0cec479b6 Record explicit live-test gates and CI follow-ups 2026-10-13 10:37:45 -04:00
Joseph Doherty 91baba8fcc Record verified corrected Gateway rollout 2026-10-13 10:15:45 -04:00
Joseph Doherty 9688707d82 Record gateway correction and reset-echo verification 2026-10-13 10:02:44 -04:00
Joseph Doherty 509114360d Record failed Gateway event-read rollout and rollback evidence 2026-10-13 09:43:48 -04:00
Joseph Doherty cfd371ce18 Record OtOpcUa documentation merge and CI follow-up evidence 2026-10-13 09:16:31 -04:00
Joseph Doherty 51a956a878 Record verified event route and ScadaBridge issue closures 2026-10-13 08:58:15 -04:00
Joseph Doherty 2a3baae301 Record verified SQL event fault fix and route probe limits 2026-10-13 08:18:12 -04:00
Joseph Doherty 10fc22b87e Record Gateway read cancellation fix and event probe evidence 2026-10-13 07:29:35 -04:00
Joseph Doherty 8825cd966f docs(index): record durable alarm identity fix 2026-10-13 07:12:19 -04:00
Joseph Doherty 69a7f8e1b5 docs(index): record historian cleanup status fix 2026-10-13 06:56:02 -04:00
Joseph Doherty 01a6313fe7 docs: record verified restart wire test fix 2026-10-13 06:30:59 -04:00
Joseph Doherty e7cfa21fda docs: record verified local Gateway metadata rollout 2026-10-13 06:19:29 -04:00
Joseph Doherty 68f1b56acf docs: record Docker auth correction and reconnect reproduction 2026-10-13 05:43:43 -04:00
Joseph Doherty b4da0deacb docs: record aggregate and catalog fixes and verification gates 2026-10-13 05:31:38 -04:00
Joseph Doherty 180403cb17 docs: record verified OtOpcUa cache coverage merge 2026-10-13 05:29:45 -04:00
Joseph Doherty 67c182ce18 docs: record gateway catalog and key follow-up merge 2026-10-13 05:06:02 -04:00
Joseph Doherty 05042e5e36 docs: record historian merge and pending verification issues 2026-10-13 04:49:11 -04:00
Joseph Doherty aed5fbf046 docs(index): record missing-tag and gateway discovery fixes 2026-10-13 04:33:36 -04:00
Joseph Doherty 00ef22823f docs(index): record OtOpcUa historian fixes and live measurement 2026-10-13 03:51:45 -04:00
Joseph Doherty c05a6d93b0 docs(index): OtOpcUa #692–#694 historian paging/AtTime/ceiling batch 2026-10-13 02:12:30 -04:00
Joseph Doherty ec2615a9d2 docs(history): OtOpcUa #689–#691 reverse walk sizing, ReturnBounds, gateway ceiling/events 2026-10-13 00:11:34 -04:00
Joseph Doherty 2a0b34e056 docs(history): OtOpcUa #685–#688 Gateway historian raw/aggregate fixes 2026-10-12 16:04:02 -04:00
Joseph Doherty 4555a3bdad docs(index): OtOpcUa live AVEVA gate — #675/#677/#679/#681 closed, historiangw#27 + #685–#688 filed 2026-10-12 14:21:18 -04:00
Joseph Doherty 681606dad0 docs(index): OtOpcUa batch #681–#684 — Gateway per-sample from raw, advertised aggregates, QuestDB tag index, Good-only aggregates 2026-10-12 13:59:34 -04:00
Joseph Doherty aa7093b53b docs(index): OtOpcUa batch #679–#680 — historian aggregate parity, Gateway Average/Count refused 2026-10-12 08:38:50 -04:00
Joseph Doherty 17cb91a315 docs(index): OtOpcUa follow-up batch #674-#678 (fast OpcUaClient recovery, reverse history reads, HistoryUpdate Primary-only, role overlays) 2026-10-11 13:20:46 -04:00
Joseph Doherty 0adfb244b2 docs(index): OtOpcUa batch #667-#673 (retention, Part 11 modified values, event CPs, OpcUaClient session loss, time-weighted aggregates, unreachable-Primary capture) 2026-10-11 10:18:36 -04:00
Joseph Doherty c6e60cb6c4 docs(index): OtOpcUa pluggable historian (SqlServer #652, QuestDB #666) 2026-10-11 04:42:08 -04:00
Joseph Doherty c713925ac7 docs(index): OtOpcUa #665 dead in-place ApplyDelta seam deleted
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:32:54 -04:00
Joseph Doherty 0b056c6fee docs(index): OtOpcUa #664 mid-connect ApplyDelta config adoption
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:17:15 -04:00
Joseph Doherty 484b176cd6 docs(index): OtOpcUa #663 in-place ApplyDelta re-subscribes data + alarms
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 08:00:11 -04:00
Joseph Doherty dd1eea2106 docs(index): OtOpcUa #662 native-alarm feed survives non-respawning deploys
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:36:04 -04:00
Joseph Doherty 63cc772e8d docs(index): OtOpcUa hw5 batch — #657 alarm restatement, #660, #661, #592 hw evidence, #662 filed 2026-10-09 07:20:35 -04:00
Joseph Doherty 3b4d81e630 docs(index): OtOpcUa hw4 batch #611/#592 history entry
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:36:47 -04:00
Joseph Doherty b6f5eb2535 docs(index): OtOpcUa hw3 batch #654-#656 history entry
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 00:19:30 -04:00
Joseph Doherty a8f5f66647 docs(index): OtOpcUa hardware batch #647-#651 + hw2 live gates 2026-10-08 22:03:14 -04:00
Joseph Doherty df21d365ec docs(history): OtOpcUa hardware-available batch #640-#646 (alarm carry-over, honest deploy acks, OpcUaClient/AbCip alarm routing)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 18:23:34 -04:00
Joseph Doherty a2b40b7922 fix(theme): .rise uses fill-mode backwards so it stops trapping position:fixed descendants (OtOpcUa #643)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:50:08 -04:00
Joseph Doherty 3c91f0f813 docs(history): OtOpcUa issue batch #626-#638 (exact condition severity, MTConnect alarms) 2026-10-08 16:33:45 -04:00
Joseph Doherty a6c4161719 docs(history): OtOpcUa hardware-available issue sweep #593-#625 2026-10-08 09:10:46 -04:00
Joseph Doherty 78ca2a6744 docs(index): OtOpcUa Gitea issue sweep #562-#591 — ScadaBridge-visible ACL/alarm-event changes
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:45:05 -04:00
Joseph Doherty 21a95c7f50 docs(history): OtOpcUa native AB transport merged — AbCip native default, AbLegacy opt-in, real-press gate passed 2026-10-07 22:45:47 -04:00
Joseph Doherty c43d7ee1ad docs(history): OtOpcUa AbCip/AbLegacy gain a native managed transport behind a Transport switch 2026-10-07 19:12:40 -04:00
Joseph Doherty d0b66bf105 docs(history): OtOpcUa TwinCAT native array subscriptions (2026-10-07) 2026-10-07 15:09:04 -04:00
Joseph Doherty 44421dc5aa docs(history): OtOpcUa FOCAS/TwinCAT/AbCip driver fixes ported from ignitionoee + real-device gates (2026-10-07) 2026-10-07 14:32:57 -04:00
Joseph Doherty 8fa7631ec4 overview: register BTDB Manager on vd03 (Wonder overlay) and QA; deployment notes 2026-10-01 02:05:44 -04:00
Joseph Doherty b62cfad48d index: BTDB master a81a480 — LDAP sign-in by user bind (Auth 0.4.1) 2026-10-01 01:44:27 -04:00
Joseph Doherty 625f207e08 index: Auth feed carries 0.4.1 2026-10-01 01:31:16 -04:00
Joseph Doherty 29bfa8fbf9 Merge fix/auth-ldap-0.4.1: Auth 0.4.1 (canonical username, empty-password guard in both modes) 2026-10-01 01:31:06 -04:00
Joseph Doherty b0582eb66a release(auth): 0.4.1 — canonical username, empty passwords refused in both modes, template guidance 2026-10-01 01:27:55 -04:00
Joseph Doherty 9090b1b511 docs(auth): prefer the down-level UserBindTemplate; warn on UPN templates (I1) 2026-10-01 01:27:55 -04:00
Joseph Doherty e3d146aa21 fix(auth): canonical username from the entry; refuse empty passwords in both bind modes
- Request UserNameAttribute in the search (de-duplicated, case-insensitive) and return its
  first value as LdapAuthResult.Username, falling back to the typed name (I2).
- The empty-password guard now applies in ServiceAccount mode too: AD treats an empty-password
  simple bind as an unauthenticated bind and reports success (I3).
- Restore the dropped explanatory comments in CompleteWithServiceAccount / SearchUser.
2026-10-01 01:27:27 -04:00
Joseph Doherty 177881c423 Merge feat/auth-ldap-user-bind: Auth 0.4.0 opt-in LDAP user bind 2026-10-01 01:08:53 -04:00
Joseph Doherty 3c42069673 docs(index): 0.4.0 entry placement 2026-10-01 01:02:47 -04:00
Joseph Doherty 00ac436ae2 release(auth): 0.4.0 — opt-in LDAP user bind 2026-10-01 00:58:55 -04:00
Joseph Doherty 302d1245cd feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection) 2026-10-01 00:55:04 -04:00
Joseph Doherty fa0ca66a05 feat(auth): normalise DOMAIN\name and name@suffix logins for user bind 2026-10-01 00:48:25 -04:00
Joseph Doherty 711e8beb2b feat(auth): LdapBindMode.User options and validation 2026-10-01 00:47:04 -04:00
Joseph Doherty 9ac6ed51d3 index: ScadaBridge main @ 3a217b94 (stale secret-mirror status rows pruned) 2026-09-29 11:17:52 -04:00
Joseph Doherty efd5d881dc index: Secrets 0.6.3 published; ScadaBridge site-secrets follow-ups (2650f794) 2026-09-28 21:47:04 -04:00
Joseph Doherty 0793159cbb docs(secrets): 0.6.3 changelog entry 2026-09-28 21:25:14 -04:00
Joseph Doherty 51b6f1ab9f index: ScadaBridge site-secrets-on-artifact-deploy merged (23a139a4); Secrets 0.6.3 on main, unpublished 2026-09-28 21:14:16 -04:00
Joseph Doherty 29d0c50f07 index: ScadaBridge secrets via artifact deploy; Secrets 0.6.3 2026-09-28 20:24:16 -04:00
Joseph Doherty adbf2eb182 fix(secrets): synchronize SecretsHubSyncStatus getters under the write lock
Review finding (Fix round 1, Task 1): the property getters read their
backing fields without synchronization while RecordSuccess/RecordFailure
write them under _gate, so a concurrent read could observe a torn
DateTimeOffset or a mix of fields from two different writes. Converted the
four auto-properties to explicit properties over private fields, each
getter taking the same lock the writers use. Public interface unchanged.
2026-09-28 17:30:33 -04:00
Joseph Doherty b392b84f21 feat(secrets): 0.6.3 — hub sweep outcome logging (in-sync debug + hourly heartbeat + recovered) and ISecretsHubSyncStatus
GrpcSecretSyncService's background sweep now records outcomes on a new
SecretsHubSyncStatus (registered singleton, exposed as public
ISecretsHubSyncStatus) and logs three new events on the outcome-recording
path: EventId 4 (Debug, every in-sync sweep), EventId 5 (Information, the
same outcome throttled to at most once per the new
SecretsGrpcHubClientOptions.InSyncLogInterval, default 1h), and EventId 6
(Information, the first sweep to succeed after one or more failures).
EventIds 1-3 are unchanged; the raw internal SweepAsync still throws on
failure exactly as before. Not yet published to the Gitea feed and
ScadaBridge is not re-pinned — that is a separate, later step.
2026-09-28 17:26:25 -04:00
Joseph Doherty 5c73be486d index: ScadaBridge b3021e7c deployed to QA vq03/vq04 (2026-09-28, migrations 57) 2026-09-28 11:12:04 -04:00
Joseph Doherty 308742b7a6 docs: ScadaBridge — main 5cc09b08 (script Files access), DEV vd03 on b3021e7c 2026-09-28 10:46:08 -04:00
Joseph Doherty 8cfe076012 docs: ScadaBridge — DEV vd03 on fe89363b + MES test machines (2026-09-28); row 126 now QA-only 2026-09-28 10:10:21 -04:00
Joseph Doherty 646af887d1 docs: winrm re-enabled for deployment activities only (2026-09-28)
Replaces the 2026-09-11 remote-exec hold: WinRM exec is allowed only for the on-box steps of a
user-requested deploy; ad-hoc diagnostics/version checks use SMB, HTTP or SQL. SMB file copies and
direct SQL logins remain always allowed. Scope rules live in the winrm skill's Scope section.
2026-09-28 09:25:12 -04:00
Joseph Doherty 1e0bfe03d4 docs: HistorianGateway row notes the Java client 2026-09-15 19:31:18 -04:00
Joseph Doherty 9934bea05a docs: ScadaBridge index row — main @ fb54ed42 (register row 126: vd03/vq03/vq04 migration rollout TODO)
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:43:33 -04:00
Joseph Doherty d0079ba1a1 docs: ScadaBridge index row — main @ ac833f5a pushed 2026-09-14
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:41:37 -04:00
Joseph Doherty 4dda768801 docs: ScadaBridge index row — local main @ 3a21cfc2 (2026-09-14: site artifact staleness, pending-updates indicator, scrollable nav); not pushed
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:38:48 -04:00
Joseph Doherty f6eef9d8eb docs: BTDB index row — master @ 695192d (2026-09-11: Manager edits, shows and audits batch transfers; ~198 ahead, unpushed)
Claude-Session: https://claude.ai/code/session_01XuWnsPJZ4XK9qPBBtnuqr2
2026-09-11 13:04:34 -04:00
Joseph Doherty 16a84f9df4 docs: ScadaBridge index row — main pushed @ 9d529b82 (2026-09-11: #36 handshake, MES-reportable alarm flag, Flow Debugger nav); deployed tip unchanged
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-11 12:30:30 -04:00
52 changed files with 2293 additions and 186 deletions
+21 -4
View File
@@ -163,8 +163,17 @@ Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on
with `TOP 1` and dropped the other branch of a lot merge (`A→C, B→C`); both now resolve the whole lot with `TOP 1` and dropped the other branch of a lot merge (`A→C, B→C`); both now resolve the whole lot
(forward to the final batch, then every batch that fed it) with one CTE pair pinned equal by (forward to the final batch, then every batch that fed it) with one CTE pair pinned equal by
`SchemaHygieneTests`. Rig proofs: `ValidateLifecycleSprocs.sql` 87/0, `docker/db/validate-clr.sql` `SchemaHygieneTests`. Rig proofs: `ValidateLifecycleSprocs.sql` 87/0, `docker/db/validate-clr.sql`
27/0 over a merged lot with a `Runtime.dbo.Events` stand-in. No branch is in flight. 27/0 over a merged lot with a `Runtime.dbo.Events` stand-in.
- **`master` is ~168 commits ahead of `origin/master` (`d147c10`) and unpushed.** Pushing is the user's `feat/manager-transfers` (merged `695192d`, 2026-09-11) gave the **Manager** the transfer side it lacked: the
batch edit page edits the batch's outgoing `MachineBatchTransfer` row as a fourth MERGEd collection in
`SaveBatchEdit.sql` (guards `50015` self, `50016` destination missing, `50017` loop, `50018` more than one;
destination row and chain walk held like `AddMachineBatchTransfer`), audited into the new
`AuditLogMachineBatchTransfer` under the batch-edit header (new TVP `MachineBatchTransferType`); a read-only
lot panel (`GetBatchLot.sql`, the same `Down`/`Lot` CTE as the CLR, pinned equal by a static test); a lineage
column on Find Batch; a Transfers section on the batch-edit audit page and PDF. Rig proofs: 28 integration
tests (three new: round trip with audit, loop refused, merged lot), smoke 17/17, lifecycle 87/0, transfer
chains 4/0, validate-clr 27/0; gate 961 tests, 0 warnings. No branch is in flight.
- **`master` is ~198 commits ahead of `origin/master` (`d147c10`) and unpushed.** Pushing is the user's
call — the remote is the corporate `ZimmerBiomet/BTDB`. call — the remote is the corporate `ZimmerBiomet/BTDB`.
- **Solution shape now:** `BTDB.slnx` = `src/ZB.MOM.WW.BTDB.Manager` (Blazor host) + - **Solution shape now:** `BTDB.slnx` = `src/ZB.MOM.WW.BTDB.Manager` (Blazor host) +
`src/ZB.MOM.WW.BTDB.Manager.Core` (Dapper data layer, embedded `Queries/*.sql`) + five test projects `src/ZB.MOM.WW.BTDB.Manager.Core` (Dapper data layer, embedded `Queries/*.sql`) + five test projects
@@ -178,8 +187,8 @@ Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on
Manager queries; every writer sproc follows the own-or-join `TRY/CATCH` template with locked lookups; Manager queries; every writer sproc follows the own-or-join `TRY/CATCH` template with locked lookups;
every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via
`ExpectedLastUpdate` → `THROW 50014`. `ExpectedLastUpdate` → `THROW 50014`.
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` + - **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` (the LDAP
`${secret:btdb/ldap/serviceaccountpassword}`, store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`, service-account secret is gone since 2026-10-01 — see below; only `dotnet run` against local glauth uses it), store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`,
master key only in `ZB_SECRETS_MASTER_KEY`. Rotation is cut-over step 4.2. master key only in `ZB_SECRETS_MASTER_KEY`. Rotation is cut-over step 4.2.
- **Claude artifacts are gitignored + untracked in btdb** (2026-09-11): `CLAUDE.md`, `.claude/`, - **Claude artifacts are gitignored + untracked in btdb** (2026-09-11): `CLAUDE.md`, `.claude/`,
`docs/plans/`, `docs/reviews/`, `*.tasks.json` — they exist on disk (btdb's `CLAUDE.md` is the repo `docs/plans/`, `docs/reviews/`, `*.tasks.json` — they exist on disk (btdb's `CLAUDE.md` is the repo
@@ -188,3 +197,11 @@ Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on
- **Open step:** phase-4 cut-over on the DEV box (`docs/operations/cut-over.md`; 4.1 parity walk-through - **Open step:** phase-4 cut-over on the DEV box (`docs/operations/cut-over.md`; 4.1 parity walk-through
gates the rest) — **blocked by the 2026-09-11 remote-access hold** (no psbridge, no winrm; UNC copies and gates the rest) — **blocked by the 2026-09-11 remote-access hold** (no psbridge, no winrm; UNC copies and
direct SQL logins still allowed). §6 item 6 (which SQL host carries DEV `BT`) is still unrecorded. direct SQL logins still allowed). §6 item 6 (which SQL host carries DEV `BT`) is still unrecorded.
- **2026-10-01 — LDAP sign-in by user bind** (merged `a81a480`): Auth 0.4.0/0.4.1 added opt-in
`LdapOptions.BindMode=User`; BTDB ships it. Sign-in binds as `UserBindTemplate` (`<DOMAIN>\{0}` —
down-level maps exactly to the sAMAccountName the group search uses), the same connection reads the
user's own `memberOf`/`displayName`, and the username returned is the directory's spelling. No service
account, no LDAP secret; the preflight requires the template and refuses `AllowInsecure`/`Transport=None`
with user bind outside Development. Local dev + docker rig stay `BindMode=ServiceAccount` (GLAuth bind DNs
embed the user's OU). Rollout to the three Manager nodes (vd03 first) and retiring the stored secret are
the open steps (plan `docs/plans/2026-10-01-ldap-user-bind.md`, gitignored).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
# ScadaBridge — index history
- **2026-10-13 existing fixes verified and issues closed.** Main `513fa1aa` already includes #35 fail-closed unavailable configured body/SQL redactors (`006202f3`) and #37 Flow Debugger structured/raw validated method inputs (`b5eb08f1`). Fresh full AuditLog project 407 passed/0 failed; FlowDebuggerPageTests 47 passed/0 failed; both terminal exit0. Remote ancestry confirmed; #35/#37 closed with evidence. No source changes/deployment/MES transactions. #36 runtime reset-echo overload is implemented, but QA/prod script port remains tracked; #30 per-site PSK deployment gate remains open.
- **2026-10-13 #36 runtime regression proof refreshed.** Rebuilt HandshakeResetEchoTests passed all three cases with local fakes, terminal exit0; an earlier no-build attempt matched no tests and is excluded from evidence. Tracker now distinguishes implemented runtime b623fdb0 from remaining QA/prod database-owned script rollout (deployment register row119, scripts13/14/36 and15/16/25/26/37/38). No database writes, deployments or fresh live rig claims.
+15 -13
View File
@@ -36,11 +36,11 @@ own `CLAUDE.md` for the full picture. See [Refreshing this index](#refreshing-th
| Project | Location | Stack | Repo | Summary | | Project | Location | Stack | Repo | Summary |
|---|---|---|---|---| |---|---|---|---|---|
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **two-subtree address space (v3.0, `master` since 2026-07-16)** — `https://zb.com/otopcua/raw` (device tree Folder→Driver→Device→TagGroup→Tag, NodeId `s=<RawPath>`) and `https://zb.com/otopcua/uns` (equipment tree Area→Line→Equipment→signal); every value has one source fanned to both NodeIds. **This is the wire contract ScadaBridge binds against** (ScadaBridge consumes v3 end-to-end; its #14 is closed). Drivers: Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient, `Sql` (SQL Server / PostgreSQL / Oracle, read-only poll), `Mqtt` (Plain + Sparkplug B, writable where a tag declares a destination), `MTConnect` (read-only), Calculation, and AVEVA Galaxy as a standard Equipment-kind driver via in-process `GalaxyDriver` → gRPC → **mxaccessgw**. Sole historian backend is **HistorianGateway**. Address-space availability guarantee: an unreadable deployment artifact is *no answer*, never an empty configuration — nodes hold last-known-good, and a driver-only node boots and serves from its LocalDb cache with central SQL down (ServiceLevel stays 240/250). Redundancy: one independent 2-node Akka mesh per application `Cluster`; Primary = oldest Up `driver` member (ServiceLevel 250) + one Secondary (240) **per `Cluster`**; cross-mesh commands ride gRPC with per-cluster PSK auth (Akka `ClusterClient` fully deleted 2026-08-06). Data plane is machine-to-machine: OPC UA UserName `apikey` + `otua_<keyId>_<secret>` minted on AdminUI `/api-keys`; `OpcUa:AllowAnonymous` defaults `true` (anonymous is a normal grantable principal); LDAP is AdminUI-login only. Node ACLs are enforced **only** behind `Security:Acl:Enabled` (default `false`); with it on, scoped `NodeAcl` grants decide **every** data-plane operation incl. browse/read/subscribe, fail closed, and reach open sessions. Every driver's authoring path now consults the driver's own runtime parse, so what authoring accepts is what the driver serves (tracker zeroed 2026-08-10, origin `d06e92c6`). **Full change history, live-gate records and the per-issue consumer-impact record for ScadaBridge: `.claude/skills/sister-project-history/otopcua.md` (skill `sister-project-history`).** | | **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **two-subtree address space (v3.0, `master` since 2026-07-16)** — `https://zb.com/otopcua/raw` (device tree Folder→Driver→Device→TagGroup→Tag, NodeId `s=<RawPath>`) and `https://zb.com/otopcua/uns` (equipment tree Area→Line→Equipment→signal); every value has one source fanned to both NodeIds. **This is the wire contract ScadaBridge binds against** (ScadaBridge consumes v3 end-to-end; its #14 is closed). Drivers: Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient, `Sql` (SQL Server / PostgreSQL / Oracle, read-only poll), `Mqtt` (Plain + Sparkplug B, writable where a tag declares a destination), `MTConnect` (read-only), Calculation, and AVEVA Galaxy as a standard Equipment-kind driver via in-process `GalaxyDriver` → gRPC → **mxaccessgw**. Historian backend is pluggable (`ServerHistorian:Backend`): **HistorianGateway** (default), **SqlServer** or **QuestDb** — the latter two add typed values with real status and OPC UA HistoryUpdate (QuestDB: soft delete + TTL retention). Address-space availability guarantee: an unreadable deployment artifact is *no answer*, never an empty configuration — nodes hold last-known-good, and a driver-only node boots and serves from its LocalDb cache with central SQL down (ServiceLevel stays 240/250). Redundancy: one independent 2-node Akka mesh per application `Cluster`; Primary = oldest Up `driver` member (ServiceLevel 250) + one Secondary (240) **per `Cluster`**; cross-mesh commands ride gRPC with per-cluster PSK auth (Akka `ClusterClient` fully deleted 2026-08-06). Data plane is machine-to-machine: OPC UA UserName `apikey` + `otua_<keyId>_<secret>` minted on AdminUI `/api-keys`; `OpcUa:AllowAnonymous` defaults `true` (anonymous is a normal grantable principal); LDAP is AdminUI-login only. Node ACLs are enforced **only** behind `Security:Acl:Enabled` (default `false`); with it on, scoped `NodeAcl` grants decide **every** data-plane operation incl. browse/read/subscribe, fail closed, and reach open sessions. Every driver's authoring path now consults the driver's own runtime parse, so what authoring accepts is what the driver serves (tracker zeroed 2026-08-10, origin `d06e92c6`). **Full change history, live-gate records and the per-issue consumer-impact record for ScadaBridge: `.claude/skills/sister-project-history/otopcua.md` (skill `sister-project-history`).** |
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** | | **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** Alarm ack (main `488649c`, 2026-10-20) authenticates as the operator and writes `<alarm>.AckMsg` so the Historian records the real operator; `AcknowledgeAlarmRequest` gained `operator_password`/`operator_domain` (proto change; OtOpcUa's `GatewayGalaxyAlarmAcknowledger` must send the password before it deploys against this). |
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | `gitea.dohertylan.com/dohertj2/ScadaBridge` | **Current `main`: `17864d1a` (deployment records only; code tip `2b9a7c3c`), pushed 2026-09-10. DEPLOYED: `2b9a7c3c` on DEV vd03 (10:45) and on the QA pair vq03/vq04 (12:20, all four nodes together, migration 53→55, protocol config row encrypted → rollback is the DB restore) — records `docs/deployment/deployment-records/2026-09-10-wonder-app-{vd03,vq03-vq04}-2b9a7c3c.md`.** Management/API/CLI parity complete (`docs/plans/2026-09-10-management-api-cli-parity-review.md`): all 149 eligible contracts exposed in 20 CLI groups, 19 missing operations added, deadline/registry/outcome gaps fixed; CLI documentation checked against all 159 leaf commands; 9554 tests verified and primary 8-node rig rebuilt with read-only smoke checks. Previously completed 2026-09-09 whole-system arch review remediation (`docs/plans/2026-09-09-arch-review.md`: 92 findings; all 13 High + 33 Medium fixed; all 46 Low findings also committed and pushed (§11); 9478 solution tests and deployed browser smoke pass, primary 8-node rig rebuilt). Adds the `ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker` child-process project and a coordinated central-pair migration that encrypts DataConnection protocol config at rest (old binaries cannot read protected rows). Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. **Inbound API keys live on the shared SQL Server store on a central pair** (design 2026-09-02, ScadaBridge register row 108): ScadaBridge is Auth's first consumer of the new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package at `0.3.0`, pointing both central nodes at one `zbauth` schema in the configuration database instead of each node's own per-node SQLite file. **Flow Debugger (including the instance picker and v1.4 step logs, 2026-09-09):** Central UI + CLI trace of one inbound-API method or value-change flow across Central / Site / Tags / External swimlanes, Live or Replay, built on `ScriptTrace` audit rows and the outbound-call audit rows; arch-reviewed and remediated the same day (F1–F13 — redacted + byte-capped inline trace copy, route-time site scope on Live runs, Replay open to `OperationalAudit` roles under `RequireFlowDebugger`; `docs/plans/2026-09-06-flow-debugger-arch-review.md` + `-remediation.md`); script entry (site → instance → script, 2026-09-08). | | **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | `gitea.dohertylan.com/dohertj2/ScadaBridge` | **Current `main`: `3a217b94` (2026-09-29: stale per-node secret-mirror status rows are now pruned; 2026-09-28: site-secrets follow-ups — a central secret-store outage fails only the sites that reference a secret, removal-only deploys are audited, each site node's mirror failures (standby included) ride the health report via a 12th replicated table `secret_mirror_status`, artifact-deploy CLI commands exit 1 on a failed site; rig gate 9/9). Previous tip `23a139a4` (2026-09-28: site secrets ride the artifact deploy and the gRPC secrets hub is retired — see below). Previous tip `5cc09b08` (2026-09-28: site scripts gain a read-only, allow-listed `Files` global — `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots`, off by default — used by the in-memory MES/Delmia/alarm-API test machines SBTEST01/02 (`docker/seed-mes-test-machine.sh`) to parse downloaded recipe files; DEV vd03 and QA vq03/vq04 on `b3021e7c` since 2026-09-28, migrations 57; register row 126 closed) — adds site artifact staleness (`Site.ArtifactRevisionHash` stamped on every successful artifact deploy, migration `AddSiteArtifactRevision`; Sites page *Artifacts current / out of date / never deployed* badge), a rail-footer **Pending updates** indicator over a process-wide `PendingUpdatesCache` (stale instances + stale-artifact sites, Deployer/Admin), and a scrollable rail nav; design `docs/plans/2026-09-14-pending-updates-design.md`. Previous tip `9d529b82` (2026-09-11) — adds the Gitea #36 reset-aware handshake (`WriteBatchAndWaitAsync` reset-echo overload), the computed-alarm `IsMesReportable` flag (template checkbox + nullable instance override, `Alarms.MesReportableAsync()`, migration `AddAlarmMesReportable`; native alarms stay on the 900–999 band) and a nav fix nesting Flow Debugger in the Deployment section. DEPLOYED: `b3021e7c` on DEV vd03 and the QA pair vq03/vq04 (2026-09-28, migrations 55→57; records `docs/deployment/deployment-records/2026-09-28-wonder-app-{vd03-fe89363b,vq03-vq04-b3021e7c}.md`).** **Merged `23a139a4` (2026-09-28): site secrets now ride the artifact deploy and the pull-only gRPC secrets hub is retired in ScadaBridge** (design `docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md`; central shares one secret store via `Secrets:Store:Provider=SqlServer`; rig live gate PASS 8/8, vd03/QA NOT deployed — rollout tracked as ScadaBridge register row 127 — see `ZB.MOM.WW.Secrets/CLAUDE.md` for the unpublished `0.6.3` hub-logging work this superseded for ScadaBridge). Management/API/CLI parity complete (`docs/plans/2026-09-10-management-api-cli-parity-review.md`): all 149 eligible contracts exposed in 20 CLI groups, 19 missing operations added, deadline/registry/outcome gaps fixed; CLI documentation checked against all 159 leaf commands; 9554 tests verified and primary 8-node rig rebuilt with read-only smoke checks. Previously completed 2026-09-09 whole-system arch review remediation (`docs/plans/2026-09-09-arch-review.md`: 92 findings; all 13 High + 33 Medium fixed; all 46 Low findings also committed and pushed (§11); 9478 solution tests and deployed browser smoke pass, primary 8-node rig rebuilt). Adds the `ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker` child-process project and a coordinated central-pair migration that encrypts DataConnection protocol config at rest (old binaries cannot read protected rows). Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. **Inbound API keys live on the shared SQL Server store on a central pair** (design 2026-09-02, ScadaBridge register row 108): ScadaBridge is Auth's first consumer of the new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package at `0.3.0`, pointing both central nodes at one `zbauth` schema in the configuration database instead of each node's own per-node SQLite file. **Flow Debugger (including the instance picker and v1.4 step logs, 2026-09-09):** Central UI + CLI trace of one inbound-API method or value-change flow across Central / Site / Tags / External swimlanes, Live or Replay, built on `ScriptTrace` audit rows and the outbound-call audit rows; arch-reviewed and remediated the same day (F1–F13 — redacted + byte-capped inline trace copy, route-time site scope on Live runs, Replay open to `OperationalAudit` roles under `RequireFlowDebugger`; `docs/plans/2026-09-06-flow-debugger-arch-review.md` + `-remediation.md`); script entry (site → instance → script, 2026-09-08). |
| **HistorianGateway** | `~/Desktop/HistorianGateway` | .NET 10 x64, gRPC, Blazor | `gitea.dohertylan.com/dohertj2/historiangw` | Single-process gRPC sidecar exposing (1) a full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared `ZB.MOM.WW.GalaxyRepository` lib. No COM, no x86 worker. **Owns `AVEVA.Historian.Client`** at `histsdk/` (ordinary owned source, edited in place — the re-vendor model is retired). Dev: dashboard `:5220` (HTTP/1.1) + gRPC h2c `:5221`; production: one `Kestrel:Endpoints:Https` with `Protocols: Http1AndHttp2` (ALPN). Default-on leased session pools amortize the historian handshake (v6 pool + a separate v8 event-session pool). Client lib `ZB.MOM.WW.HistorianGateway.Client` + Contracts are on the Gitea feed at `0.3.0`; **OtOpcUa is the first consumer** (its sole historian backend since PR #423); ScadaBridge has no coupling. Known 2023 R2 server-side gates: native event reads return 0 rows (C2, won't-fix — SQL-path `ReadEvents` via `Runtime.dbo.Events` is the shipped workaround), `Int1`/`UInt1` analog-tag creation is degenerate (OtOpcUa maps Boolean → `Int2`), string historical writes non-convergent (follow-ups #18–#25). Only remaining archreview item: the secure-by-default TLS flip, deferred to 1.0.0. **Full change history (ArchReview cycles 1–2, pending.md items, PR list): `.claude/skills/sister-project-history/historiangateway.md`.** | | **HistorianGateway** | `~/Desktop/HistorianGateway` | .NET 10 x64, gRPC, Blazor | `gitea.dohertylan.com/dohertj2/historiangw` | Single-process gRPC sidecar exposing (1) a full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared `ZB.MOM.WW.GalaxyRepository` lib. No COM, no x86 worker. **Owns `AVEVA.Historian.Client`** at `histsdk/` (ordinary owned source, edited in place — the re-vendor model is retired). Dev: dashboard `:5220` (HTTP/1.1) + gRPC h2c `:5221`; production: one `Kestrel:Endpoints:Https` with `Protocols: Http1AndHttp2` (ALPN). Default-on leased session pools amortize the historian handshake (v6 pool + a separate v8 event-session pool). Client lib `ZB.MOM.WW.HistorianGateway.Client` + Contracts are on the Gitea feed at `0.3.0`; **OtOpcUa is the first consumer** (its sole historian backend since PR #423); ScadaBridge has no coupling. Known 2023 R2 server-side gates: native event reads return 0 rows (C2, won't-fix — SQL-path `ReadEvents` via `Runtime.dbo.Events` is the shipped workaround), `Int1`/`UInt1` analog-tag creation is degenerate (OtOpcUa maps Boolean → `Int2`), string historical writes non-convergent (follow-ups #18–#25). Only remaining archreview item: the secure-by-default TLS flip, deferred to 1.0.0. A Java client, `com.zb.mom.ww.historiangw:zb-mom-ww-historiangw-client:0.1.0` (Gitea Maven feed, `clients/java/`), is consumed by the ignitionoee repo's `modules/historian` Ignition module. **Full change history (ArchReview cycles 1–2, pending.md items, PR list): `.claude/skills/sister-project-history/historiangateway.md`.** |
| **BTDB** (Batch Tracker) | `~/Desktop/btdb` — local `master` @ `5f0da01` (2026-09-11), **~168 commits ahead of `origin/master` (`d147c10`, 2023) and unpushed**; no branch in flight (`feat/transfer-machine-batch` and `fix/transfer-lot-walk` — DB-6 reopened: forward loop guard, whole-lot chain walks incl. merges — both merged 2026-09-11). **Claude artifacts are gitignored + untracked there** (`CLAUDE.md`, `.claude/`, `docs/plans/`, `docs/reviews/`, `*.tasks.json`) — local only, never to the corporate remote. | SQL Server SSDT project + SQL CLR (net472; **Windows-only build**, the docker rig rebuilds it cross-platform from the same sources); **BTDB Manager = Blazor Web App net10.0** on the family libs (Auth 0.3.0, Theme 0.4.1, Health 0.3.0, Telemetry + Configuration 0.1.0, Secrets 0.6.2), Dapper + embedded queries, self-contained win-x64 NSSM service `ZB.MOM.WW.BTDB.Manager` on `:9877` (→ `:9876` after cut-over) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03:3000` (not on `gitea.dohertylan.com`) | The `BT` SQL Server database that is the plant's **batch / cycle / work-order system of record for data collection**. Galaxy machine objects write through `StartMachineBatch` / `StartMachineCycle` / `End*` / `AddMachineBatchWorkOrder` / `AddOtherData*` / `AddMachineBatchTransfer` / **`TransferMachineBatch`** (new 2026-09-11: end source if open + start destination by `Code` + copy not-moved-out work orders + link, one owned transaction) (own-or-join transaction template, `THROW 50000+`, all `GETUTCDATE()`); reports pull per-batch time-series **out of the AVEVA Historian through CLR sprocs** (`OPENQUERY(INSQL, …)` + `Runtime.dbo.*`), so **BT must be co-tenant with the Historian's `Runtime` DB** (`wonder-sql-*`). MESAPI and **ScadaBridge's inbound API** resolve `SAPID → Machine.Code`; no checked-in ScadaBridge script calls a BT sproc. **State 2026-09-11 (all merged on local `master`):** the Blazor conversion phases 1–3 (legacy net6.0 MVC app, Telerik reports and the mis-named `install.bat` deleted), the 2026-09-04 full review remediation (12 work packages, 60 findings fixed, 0 open; static invariant tests over the SQL + CLR sources, opt-in rig integration tests behind `BTDB_TEST_CONNECTION`, Gitea Actions gate `./build.sh`), and the DARS sprint-1 schema (`MachineBatchTransfer`, `ContainerID`/`MoveOutTimestamp` on `MachineBatchWO`, nestable `MachineGroup.ParentMachineGroupID`, Gen1 reactor load script) — the old `DARS/WW_ARCH/BatchTracker_DB/` fork and the `feat/manager-blazor` worktree are superseded. **Phase-4 cut-over on the DEV box** (`docs/operations/cut-over.md`) is the open step and is **blocked by the 2026-09-11 remote-access hold**. Repo orientation lives in btdb's own (gitignored) `CLAUDE.md`. **Full schema/contract notes, family edges and the gap analysis: `.claude/skills/sister-project-history/btdb.md`.** **Local Docker rig:** `btdb/docker/` — runtime-only image on `:9877` joined to `scadabridge-net` + the redundant glauth pair, registered in Overview as **BTDB Manager**; `docker/deploy-db.sh` publishes `BT` (dacpac + CLR via two side projects under `docker/db/`, plus a `Runtime` stand-in) into `scadabridge-mssql`; `smoke-queries.sh`, `validate-config-import.sh`, `db/validate-clr.sql` and `Database/Scripts/Validation/*.sql` are the rig gates. | | **BTDB** (Batch Tracker) | `~/Desktop/btdb` — local `master` @ `a81a480` (2026-10-01), **~211 commits ahead of `origin/master` (`d147c10`, 2023) and unpushed**; no branch in flight (`feat/transfer-machine-batch` and `fix/transfer-lot-walk` — DB-6 reopened: forward loop guard, whole-lot chain walks incl. merges — both merged 2026-09-11; `feat/manager-transfers` — the Manager edits, shows and audits transfers — merged `695192d` the same day). **Claude artifacts are gitignored + untracked there** (`CLAUDE.md`, `.claude/`, `docs/plans/`, `docs/reviews/`, `*.tasks.json`) — local only, never to the corporate remote. | SQL Server SSDT project + SQL CLR (net472; **Windows-only build**, the docker rig rebuilds it cross-platform from the same sources); **BTDB Manager = Blazor Web App net10.0** on the family libs (Auth 0.4.1 — **sign-in by user bind, `BindMode=User`, no LDAP service account**, Theme 0.4.1, Health 0.3.0, Telemetry + Configuration 0.1.0, Secrets 0.6.2), Dapper + embedded queries, self-contained win-x64 NSSM service `ZB.MOM.WW.BTDB.Manager` on `:9877` (→ `:9876` after cut-over) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03:3000` (not on `gitea.dohertylan.com`) | The `BT` SQL Server database that is the plant's **batch / cycle / work-order system of record for data collection**. Galaxy machine objects write through `StartMachineBatch` / `StartMachineCycle` / `End*` / `AddMachineBatchWorkOrder` / `AddOtherData*` / `AddMachineBatchTransfer` / **`TransferMachineBatch`** (new 2026-09-11: end source if open + start destination by `Code` + copy not-moved-out work orders + link, one owned transaction) (own-or-join transaction template, `THROW 50000+`, all `GETUTCDATE()`); reports pull per-batch time-series **out of the AVEVA Historian through CLR sprocs** (`OPENQUERY(INSQL, …)` + `Runtime.dbo.*`), so **BT must be co-tenant with the Historian's `Runtime` DB** (`wonder-sql-*`). MESAPI and **ScadaBridge's inbound API** resolve `SAPID → Machine.Code`; no checked-in ScadaBridge script calls a BT sproc. **State 2026-09-11 (all merged on local `master`):** the Blazor conversion phases 1–3 (legacy net6.0 MVC app, Telerik reports and the mis-named `install.bat` deleted), the 2026-09-04 full review remediation (12 work packages, 60 findings fixed, 0 open; static invariant tests over the SQL + CLR sources, opt-in rig integration tests behind `BTDB_TEST_CONNECTION`, Gitea Actions gate `./build.sh`), and the DARS sprint-1 schema (`MachineBatchTransfer`, `ContainerID`/`MoveOutTimestamp` on `MachineBatchWO`, nestable `MachineGroup.ParentMachineGroupID`, Gen1 reactor load script) — the old `DARS/WW_ARCH/BatchTracker_DB/` fork and the `feat/manager-blazor` worktree are superseded. **Phase-4 cut-over on the DEV box** (`docs/operations/cut-over.md`) is the open step and is **blocked by the 2026-09-11 remote-access hold**. Repo orientation lives in btdb's own (gitignored) `CLAUDE.md`. **Full schema/contract notes, family edges and the gap analysis: `.claude/skills/sister-project-history/btdb.md`.** **Local Docker rig:** `btdb/docker/` — runtime-only image on `:9877` joined to `scadabridge-net` + the redundant glauth pair, registered in Overview as **BTDB Manager**; `docker/deploy-db.sh` publishes `BT` (dacpac + CLR via two side projects under `docker/db/`, plus a `Runtime` stand-in) into `scadabridge-mssql`; `smoke-queries.sh`, `validate-config-import.sh`, `db/validate-clr.sql` and `Database/Scripts/Validation/*.sql` are the rig gates. |
## Cross-project relationships ## Cross-project relationships
@@ -159,14 +159,14 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation | | Component | Status | Goal | Design | Implementation |
|---|---|---|---|---| |---|---|---|---|---|
| Auth (login / identity / authz) | Adopted. Feed carries **`0.3.0`** (2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | | Auth (login / identity / authz) | Adopted. Feed carries **`0.4.1`** (2026-10-01; 0.4.0 the same day) (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. **0.4.1** fixes the review findings: `Username` is canonicalised from the entry's `UserNameAttribute`, empty passwords are refused in both bind modes (a `ServiceAccount`-mode behaviour change), and the docs prefer the down-level `EXAMPLE\{0}` template over UPN. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); Every deployed wonder box (DEV vd03 + QA vq03/vq04) runs its repo tip as of 2026-09-10, so the deployed Theme is 0.4.1 everywhere. **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) | | UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); Every deployed wonder box (DEV vd03 + QA vq03/vq04) runs its repo tip as of 2026-09-10, so the deployed Theme is 0.4.1 everywhere. **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) | | Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) | | Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed — the "local only / not yet pushed" caveat was stale) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) | | Config + validation (options / startup validation) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed — the "local only / not yet pushed" caveat was stale) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| Audit (event model + writer seam) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed to origin) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) | | Audit (event model + writer seam) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed to origin) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | **Adopted** (feed `0.1.0` + `0.2.0`; consumed at `0.2.0` by HistorianGateway **and mxaccessgw**, whose Server wires `AddZbGalaxyRepository` — verified 2026-07-18; the prior "mxaccessgw adoption is a follow-on" claim was stale) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) | | Galaxy Repository (object-hierarchy SQL browse + gRPC service) | **Adopted** (feed `0.1.0` + `0.2.0`; consumed at `0.2.0` by HistorianGateway **and mxaccessgw**, whose Server wires `AddZbGalaxyRepository` — verified 2026-07-18; the prior "mxaccessgw adoption is a follow-on" claim was stale) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) |
| Secrets (encrypted store + `${secret:}` resolution) | Built; family version **`0.6.2`**, published. Pins: **all four apps `0.6.2`** (verified against each repo's checked-in pins AND its origin default branch 2026-09-03 — the earlier "OtOpcUa unpushed / mxgw 0.6.0 / ScadaBridge 0.5.1" notes were stale). **Target `0.6.1`+, never `0.6.0`, and use the 4-arg `AddZbSecrets(..., contentRootPath)` overload at every pre-host `${secret:}` expander call site — the pin alone does not close the hole.** `Secrets:SqlitePath` must be absolute and outside the content root (fail-closed, no opt-out; the 2026-08-09 mxgw outage). Production topologies decided (scadaproj#3/#4 closed): OtOpcUa = Akka pair-local, ScadaBridge = gRPC hub over ONE shared central SQL store (`0.5.1` minimum for central pairs); both default-off. Full narrative (`0.1.x`→`0.6.2`, incidents, live gates, container caveat, `NU1102` note): [`ZB.MOM.WW.Secrets/CLAUDE.md`](ZB.MOM.WW.Secrets/CLAUDE.md). | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) | | Secrets (encrypted store + `${secret:}` resolution) | Built; family version **`0.6.3`** (published 2026-09-28; apps still pin `0.6.2`). Pins: **all four apps `0.6.2`** (verified against each repo's checked-in pins AND its origin default branch 2026-09-03 — the earlier "OtOpcUa unpushed / mxgw 0.6.0 / ScadaBridge 0.5.1" notes were stale). **Target `0.6.1`+, never `0.6.0`, and use the 4-arg `AddZbSecrets(..., contentRootPath)` overload at every pre-host `${secret:}` expander call site — the pin alone does not close the hole.** `Secrets:SqlitePath` must be absolute and outside the content root (fail-closed, no opt-out; the 2026-08-09 mxgw outage). Production topologies decided (scadaproj#3/#4 closed): OtOpcUa = Akka pair-local (still on the hub for its own topology); **ScadaBridge retired the gRPC secrets hub for itself (2026-09-28, ScadaBridge `main` @ `23a139a4`, follow-ups `2650f794`) — sites now receive secrets via Deploy Artifacts instead of the hub, central still on ONE shared SQL store (`Secrets:Store:Provider=SqlServer`, `0.5.1` minimum)**; both default-off elsewhere. **`0.6.3` (hub sweep outcome logging + public `ISecretsHubSyncStatus`) is PUBLISHED to the feed (2026-09-28) but pinned by no app** — ScadaBridge, its original motivation, retired the hub for itself; it is there for the other hub consumer (OtOpcUa) to adopt. Full narrative (`0.1.x`→`0.6.3`, incidents, live gates, container caveat, `NU1102` note): [`ZB.MOM.WW.Secrets/CLAUDE.md`](ZB.MOM.WW.Secrets/CLAUDE.md). | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) |
| LocalDb (embedded cache + 2-node sync) | **Adopted** by ScadaBridge (Phases 1+2 merged; pins **`0.3.4`**) and OtOpcUa (Phases 1+2 merged; pins **`0.3.4`** since 2026-09-03, `b8b4c18f`) — no version skew remains (every release is wire-compatible, so one would be supported). Replication is **default-OFF**; enabled on the docker rigs' site-a pairs and on the **QA pair vq03/vq04** (ScadaBridge site pair + OtOpcUa pair — OtOpcUa's sync only actually CONNECTED after the 2026-09-10 `b8b4c18f` upgrade; the old build answered the sync POST with the login 302); DEV vd03 is single-node. Operational constraints: a pair stops/starts together (no rolling upgrade), a node offline > `TombstoneRetention` (7 d) can resurrect deleted rows. Full release narrative (`0.1.1`→`0.3.4`) + gates: [`ZB.MOM.WW.LocalDb/CLAUDE.md`](ZB.MOM.WW.LocalDb/CLAUDE.md). | Shared `ZB.MOM.WW.LocalDb` lib | [`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md) + the two adoption designs ([ScadaBridge](docs/plans/2026-07-19-scadabridge-localdb-design.md), [OtOpcUa](docs/plans/2026-07-20-otopcua-localdb-design.md)) | [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) | | LocalDb (embedded cache + 2-node sync) | **Adopted** by ScadaBridge (Phases 1+2 merged; pins **`0.3.4`**) and OtOpcUa (Phases 1+2 merged; pins **`0.3.4`** since 2026-09-03, `b8b4c18f`) — no version skew remains (every release is wire-compatible, so one would be supported). Replication is **default-OFF**; enabled on the docker rigs' site-a pairs and on the **QA pair vq03/vq04** (ScadaBridge site pair + OtOpcUa pair — OtOpcUa's sync only actually CONNECTED after the 2026-09-10 `b8b4c18f` upgrade; the old build answered the sync POST with the login 302); DEV vd03 is single-node. Operational constraints: a pair stops/starts together (no rolling upgrade), a node offline > `TombstoneRetention` (7 d) can resurrect deleted rows. Full release narrative (`0.1.1`→`0.3.4`) + gates: [`ZB.MOM.WW.LocalDb/CLAUDE.md`](ZB.MOM.WW.LocalDb/CLAUDE.md). | Shared `ZB.MOM.WW.LocalDb` lib | [`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md) + the two adoption designs ([ScadaBridge](docs/plans/2026-07-19-scadabridge-localdb-design.md), [OtOpcUa](docs/plans/2026-07-20-otopcua-localdb-design.md)) | [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) |
Each hosted library directory carries its own `CLAUDE.md` with the full version narrative, consumer matrix and Each hosted library directory carries its own `CLAUDE.md` with the full version narrative, consumer matrix and
@@ -190,13 +190,15 @@ publish; registry `BaseUrl` host rule) and the QA topology note: [`ZB.MOM.WW.Ove
> is orthogonal to the deployment tier. No production box has been identified. Full host/port > is orthogonal to the deployment tier. No production box has been identified. Full host/port
> inventory for both tiers: [`deployment.md`](deployment.md) §2.1 + §7.1. > inventory for both tiers: [`deployment.md`](deployment.md) §2.1 + §7.1.
> >
> ⛔ **Remote command execution on the wonder boxes is DISABLED (2026-09-11).** The `winrm` skill > ⛔ **Remote command execution on the wonder boxes: DEPLOYMENT ACTIVITIES ONLY (2026-09-28).** The
> (WinRM exec/ps, service control) set off the security software and has been moved to > `winrm` skill was disabled 2026-09-11 (WinRM exec/ps set off the security software) and re-enabled
> `~/.claude/skills-disabled/winrm`; do not use it or stand up a replacement channel until the user > 2026-09-28 scoped to deploys: remote exec only for the on-box steps of a deploy the user asked for
> clears one. **Still allowed:** UNC/SMB file copies to the boxes and logging directly into the SQL > (preflight, backup, stop/swap/start, migration, post-deploy verify, rollback). No ad-hoc diagnostics,
> Servers. Any deploy step that needs a command run on a box is blocked until a new channel is found. > log grepping or version checks over WinRM — use SMB, HTTP or SQL. **Always allowed:** UNC/SMB file
> copies and logging directly into the SQL Servers. Scope rules live in the skill's *Scope* section.
> >
> **Deployed state (2026-09-10):** DEV and both QA nodes run every repo tip — ScadaBridge `2b9a7c3c`, > **Deployed state (2026-09-10; ScadaBridge DEV + QA updated 2026-09-28):** DEV and both QA nodes run every repo tip — ScadaBridge `2b9a7c3c`
> (**DEV vd03 now `b3021e7c`**, migrations 57, script `Files` access on `E:\ApiInstall\ScadaBridge\recipes`, plus the in-memory MES/Delmia/alarm test machines SBTEST01/02 — QA vq03/vq04 also `b3021e7c` since 2026-09-28, migrations 57, file access off, `SimpleAlarmStatusRequest` copied from DEV),
> OtOpcUa `b8b4c18f`, mxaccessgw `4f931e4` (Server + x86 Worker), HistorianGateway `d8d890c`, Overview > OtOpcUa `b8b4c18f`, mxaccessgw `4f931e4` (Server + x86 Worker), HistorianGateway `d8d890c`, Overview
> `846b77f`/`818775b3` (code-identical). All driven from the Mac by the `winrm` skill (WinRM 5985 + SMB 445, > `846b77f`/`818775b3` (code-identical). All driven from the Mac by the `winrm` skill (WinRM 5985 + SMB 445,
> nothing installed on the boxes); psbridge and servecli are retired. Per-box records: `deployment.md` §9. > nothing installed on the boxes); psbridge and servecli are retired. Per-box records: `deployment.md` §9.
+6 -6
View File
@@ -5,12 +5,12 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<!-- 0.3.0: new ZB.MOM.WW.Auth.ApiKeys.SqlServer package — a shared SQL Server API-key store <!-- 0.4.0: opt-in LdapOptions.BindMode = User — authenticate by binding as the signing-in user
for clustered hosts (e.g. a ScadaBridge central pair), fixing the per-node SQLite store (UserBindTemplate) and read their own groups on the same connection; no service account.
going out of sync on failover. Adds the IApiKeyStoreMigrator abstraction (SQLite ServiceAccount stays the default, so existing consumers need no change. -->
implements it unchanged). SQLite stays the default for every existing consumer; no <!-- 0.4.1: Username canonicalised from the entry's UserNameAttribute; empty passwords refused
consumer code change is required to move to 0.3.0. --> in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. -->
<Version>0.3.0</Version> <Version>0.4.1</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
+17 -1
View File
@@ -30,7 +30,23 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
## Versioning ## Versioning
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.3.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages. All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
### 0.4.1 — LDAP review fixes
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
- **Behaviour change for `ServiceAccount`-mode consumers:** an empty (or null) password is now refused by the library as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone.
- **Template guidance:** prefer the down-level `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
- No `Abstractions` contract change.
### 0.4.0 — LDAP sign-in by user bind (opt-in)
- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) and **`AcceptDomainQualifiedNames`** (default `true`).
- In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated.
- Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`.
- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling (0.4.1 also canonicalises its case from the entry).
- No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged.
- `UserBindTemplate` output is used verbatim as the bind name, so use a down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) template; a DN template is not escaped.
### 0.3.0 — SQL Server API-key store (ScadaBridge central pair) ### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)
@@ -4,6 +4,20 @@ namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
public enum LdapTransport { Ldaps, StartTls, None } public enum LdapTransport { Ldaps, StartTls, None }
/// <summary>How <see cref="LdapOptions"/> authenticates a sign-in.</summary>
public enum LdapBindMode
{
/// <summary>Bind as the service account, search for the user, re-bind as the user (0.3.0 behaviour).</summary>
ServiceAccount,
/// <summary>
/// Bind as the signing-in user (identity built from <see cref="LdapOptions.UserBindTemplate"/>);
/// a successful bind authenticates them, and the same connection reads their own entry for groups.
/// No service account is used.
/// </summary>
User,
}
public sealed record LdapOptions public sealed record LdapOptions
{ {
public bool Enabled { get; init; } = true; public bool Enabled { get; init; } = true;
@@ -19,6 +33,33 @@ public sealed record LdapOptions
public string GroupAttribute { get; init; } = "memberOf"; public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000; public int ConnectionTimeoutMs { get; init; } = 10_000;
/// <summary>
/// Selects the authentication flow. <see cref="LdapBindMode.ServiceAccount"/> (the default) is the
/// 0.3.0 bind-then-search-then-bind flow; <see cref="LdapBindMode.User"/> binds as the user and needs
/// no <see cref="ServiceAccountDn"/> / <see cref="ServiceAccountPassword"/>.
/// </summary>
public LdapBindMode BindMode { get; init; } = LdapBindMode.ServiceAccount;
/// <summary>
/// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the
/// normalised login name. Must contain <c>{0}</c> exactly once.
/// <para>
/// Prefer the down-level form <c>EXAMPLE\{0}</c>: it maps exactly to the <c>sAMAccountName</c> the
/// group search uses, so the account that binds is the account whose groups are read. A UPN
/// template (<c>{0}@example.com</c>) is only correct where every account's UPN prefix equals its
/// <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit <c>userPrincipalName</c>, so
/// a mismatch can fail sign-in or bind a different account than the one whose groups are read.
/// </para>
/// </summary>
public string UserBindTemplate { get; init; } = "";
/// <summary>
/// <see cref="LdapBindMode.User"/> only: when <see langword="true"/> (default) a login typed as
/// <c>DOMAIN\name</c> or <c>name@suffix</c> is reduced to <c>name</c>; when <see langword="false"/>
/// such input is refused as bad credentials without contacting the directory.
/// </summary>
public bool AcceptDomainQualifiedNames { get; init; } = true;
/// <summary> /// <summary>
/// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback) /// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback)
/// fails with a system-side error (connect/TLS, service-account bind, or search). /// fails with a system-side error (connect/TLS, service-account bind, or search).
@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary>
/// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>:
/// the qualifier is stripped before the name reaches the bind template and the search filter, so audit
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. Case is NOT
/// normalised here: the returned username is canonicalised from the directory entry's
/// <c>UserNameAttribute</c> value, so <c>JDoe</c> and <c>jdoe</c> also yield one identity; this name is
/// only the fallback when the entry has no such value.
/// </summary>
internal static class LdapUserName
{
/// <summary>
/// Trims <paramref name="input"/> and, when <paramref name="acceptDomainQualified"/>, strips one
/// leading <c>DOMAIN\</c> or one trailing <c>@suffix</c>. Returns <see langword="false"/> (with
/// <paramref name="name"/> empty) for blank input, for both forms at once, for a repeated
/// separator, for an empty side, or for any qualified form when <paramref name="acceptDomainQualified"/>
/// is <see langword="false"/>.
/// </summary>
public static bool TryNormalize(string? input, bool acceptDomainQualified, out string name)
{
name = "";
if (string.IsNullOrWhiteSpace(input))
return false;
var s = input.Trim();
var slash = s.IndexOf('\\');
var at = s.IndexOf('@');
if (slash >= 0 || at >= 0)
{
if (!acceptDomainQualified || (slash >= 0 && at >= 0))
return false;
if (slash >= 0)
{
if (slash == 0 || s.IndexOf('\\', slash + 1) >= 0)
return false;
s = s[(slash + 1)..];
}
else
{
// Unlike the slash branch (whose extracted suffix is checked for emptiness by the
// final length check below), the extracted piece here is the *prefix* — so an empty
// domain part (a trailing "@", e.g. "jdoe@") must be rejected explicitly.
if (at == s.Length - 1 || s.IndexOf('@', at + 1) >= 0)
return false;
s = s[..at];
}
s = s.Trim();
}
if (s.Length == 0)
return false;
name = s;
return true;
}
}
@@ -38,6 +38,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome /// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
/// is one login starting at a stale endpoint and walking on. /// is one login starting at a stale endpoint and walking on.
/// </para> /// </para>
/// <para>
/// With <c>BindMode = User</c> the service-account bind and the user re-bind are replaced by a
/// single bind as the user (<see cref="LdapOptions.UserBindTemplate"/>); see <see cref="CompleteAsUser"/>.
/// </para>
/// </remarks> /// </remarks>
public sealed class LdapAuthService : ILdapAuthService public sealed class LdapAuthService : ILdapAuthService
{ {
@@ -141,6 +145,21 @@ public sealed class LdapAuthService : ILdapAuthService
// filter and the returned result (avoids two identities for one person). // filter and the returned result (avoids two identities for one person).
username = username.Trim(); username = username.Trim();
// 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a
// simple bind with an empty password as an UNAUTHENTICATED bind and reports success —
// the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone.
// Refused here, before any network call, so no consumer depends on its own edge check.
if (string.IsNullOrEmpty(password))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
// 3c. User-bind mode: reduce the name to the bare account name the bind template and the
// search filter share. (The returned username is canonicalised from the entry later.)
if (_options.BindMode == LdapBindMode.User
&& !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration // 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour. // (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
var preferred = Volatile.Read(ref _preferredIndex); var preferred = Volatile.Read(ref _preferredIndex);
@@ -235,74 +254,9 @@ public sealed class LdapAuthService : ILdapAuthService
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect"); throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
} }
// 6. Service-account bind so we can search for the user's DN. A bind failure return _options.BindMode == LdapBindMode.User
// here is a service-account misconfiguration — DISTINCT from a user-credential ? CompleteAsUser(conn, username, password)
// failure — so it maps to ServiceAccountBindFailed. : CompleteWithServiceAccount(conn, username, password);
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
}
// 7. Search for the user entry by the configured username attribute. A search
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
// closest system-side bucket until a DirectoryUnavailable value exists.
IReadOnlyList<LdapSearchEntry> entries;
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
}
// 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// Both are authoritative answers from a healthy directory, so they end the walk.
// We never attempt a user bind against an ambiguous DN.
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2)
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
// here is the end user's bad credentials — the directory answered, so this is
// returned directly and never retried against another endpoint.
try
{
conn.Bind(entry.Dn, password);
}
catch (LdapException)
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
// 10. Group extraction. Fail closed: an empty/missing group set, or any error while
// extracting groups, is a GroupLookupFailed — never a zero-group success. This
// too is an answer about the user, not about the endpoint, so it ends the walk.
IReadOnlyList<string> groups;
try
{
groups = ExtractGroups(entry);
}
catch
{
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
}
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
// 11. Success — and only here, with a verified password and >= 1 group.
var displayName = ExtractDisplayName(entry, username);
return LdapAuthResult.Success(username, displayName, groups);
} }
catch (StageFailure stage) catch (StageFailure stage)
{ {
@@ -323,6 +277,121 @@ public sealed class LdapAuthService : ILdapAuthService
} }
} }
/// <summary>Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.</summary>
private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
{
// 6. Service-account bind so we can search for the user's DN. A bind failure here is a
// service-account misconfiguration — DISTINCT from a user-credential failure — so it
// maps to ServiceAccountBindFailed.
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
}
// 7–8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// Both are authoritative answers from a healthy directory, so they end the walk.
// We never attempt a user bind against an ambiguous DN.
var entries = SearchUser(conn, username);
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2)
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure here is
// the end user's bad credentials — the directory answered, so this is returned directly
// and never retried against another endpoint.
try
{
conn.Bind(entry.Dn, password);
}
catch (LdapException)
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
return SucceedFromEntry(entry, username);
}
/// <summary>
/// <see cref="LdapBindMode.User"/>: the user's own bind is the authentication; the same
/// connection then reads their entry. Only result 49 (invalid credentials — AD's answer for a
/// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
/// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
/// </summary>
private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
{
var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
try
{
conn.Bind(bindName, password);
}
catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
}
// After a good bind, anything but exactly one readable entry is a failure to establish the
// user's groups — never a success, and never UserNotFound (the directory just accepted them).
var entries = SearchUser(conn, username);
if (entries.Count != 1)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
return SucceedFromEntry(entries[0], username);
}
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
{
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
// to ServiceAccountBindFailed, the closest system-side bucket (same limitation as
// Connect), until a DirectoryUnavailable value exists.
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
}
}
/// <summary>
/// Steps 10–11: fail-closed group extraction, then success. The returned username is the
/// entry's own <see cref="LdapOptions.UserNameAttribute"/> value (the directory's spelling), so
/// <c>JDoe</c> and <c>jdoe</c> sign in as one identity; the typed (normalised) name is the
/// fallback when the entry carries no such value.
/// </summary>
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
{
IReadOnlyList<string> groups;
try
{
groups = ExtractGroups(entry);
}
catch
{
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
}
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
var canonical = ExtractUserName(entry, username);
return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups);
}
/// <summary> /// <summary>
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/> /// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
/// out of a stage to the single fail-closed catch site. Never escapes this type. /// out of a stage to the single fail-closed catch site. Never escapes this type.
@@ -337,21 +406,42 @@ public sealed class LdapAuthService : ILdapAuthService
public LdapAuthFailure Failure { get; } public LdapAuthFailure Failure { get; }
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>search</c>), for diagnostics.</summary> /// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>user-bind</c> / <c>search</c>), for diagnostics.</summary>
public string Stage { get; } public string Stage { get; }
} }
/// <summary> /// <summary>
/// Builds the distinct attribute list requested from the directory. The display-name /// Builds the distinct attribute list requested from the directory: display name, groups and
/// and group attributes are de-duplicated so we never request the same attribute twice /// the username attribute (read back as the canonical username). They are de-duplicated,
/// when an operator configures them to the same value. /// case-insensitively, so we never request the same attribute twice when an operator
/// configures two of them to the same value.
/// </summary> /// </summary>
private IReadOnlyList<string> BuildSearchAttributes() private IReadOnlyList<string> BuildSearchAttributes()
{ {
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase)) var attributes = new List<string>(3);
return new[] { _options.DisplayNameAttribute }; foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
{
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
attributes.Add(name);
}
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute }; return attributes;
}
/// <summary>
/// Returns the first non-blank value of the configured username attribute — the directory's
/// canonical spelling — falling back to the (already normalised) typed username.
/// </summary>
private string ExtractUserName(LdapSearchEntry entry, string username)
{
if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values)
&& values.Count > 0
&& !string.IsNullOrWhiteSpace(values[0]))
{
return values[0];
}
return username;
} }
/// <summary> /// <summary>
@@ -18,8 +18,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item> /// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every /// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
/// search runs against);</item> /// search runs against);</item>
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value /// <item>in <see cref="LdapBindMode.ServiceAccount"/> mode <see cref="LdapOptions.ServiceAccountDn"/>
/// would bind anonymously, defeating the search-then-bind authentication flow;</item> /// must be specified — an empty value would bind anonymously, defeating the search-then-bind
/// authentication flow; in <see cref="LdapBindMode.User"/> mode <see cref="LdapOptions.UserBindTemplate"/>
/// must contain <c>{0}</c> exactly once instead;</item>
/// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as /// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as
/// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant /// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant
/// until the primary outage it exists to survive.</item> /// until the primary outage it exists to survive.</item>
@@ -63,7 +65,20 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
"set it to the search-base DN (e.g. \"dc=example,dc=com\")."); "set it to the search-base DN (e.g. \"dc=example,dc=com\").");
} }
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn)) if (options.BindMode == LdapBindMode.User)
{
// User mode binds as the signing-in user, so there is no service account to require.
// The template is the bind identity: without exactly one {0} every login would bind as
// the same fixed name (or as a name with the user spliced in twice).
if (CountPlaceholders(options.UserBindTemplate) != 1)
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.UserBindTemplate)} must contain {{0}} exactly once when " +
$"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"{{0}}@example.com\" " +
"or \"EXAMPLE\\{0}\".");
}
}
else if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
{ {
return ValidateOptionsResult.Fail( return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " + $"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
@@ -88,4 +103,16 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
return ValidateOptionsResult.Success; return ValidateOptionsResult.Success;
} }
private static int CountPlaceholders(string? template)
{
if (string.IsNullOrEmpty(template))
return 0;
var count = 0;
for (var i = template.IndexOf("{0}", StringComparison.Ordinal); i >= 0;
i = template.IndexOf("{0}", i + 3, StringComparison.Ordinal))
count++;
return count;
}
} }
@@ -134,4 +134,30 @@ public class ServiceCollectionExtensionsTests
await host.StartAsync(); await host.StartAsync();
await host.StopAsync(); await host.StopAsync();
} }
[Fact]
public void AddZbLdapAuth_BindsUserBindModeKeys()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{LdapSection}:Server"] = LdapServer,
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
[$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
[$"{LdapSection}:BindMode"] = "User",
[$"{LdapSection}:UserBindTemplate"] = "{0}@corp.test",
[$"{LdapSection}:AcceptDomainQualifiedNames"] = "false",
})
.Build();
var services = new ServiceCollection();
services.AddZbLdapAuth(config, LdapSection);
using ServiceProvider provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<LdapOptions>>().Value;
Assert.Equal(LdapBindMode.User, options.BindMode);
Assert.Equal("{0}@corp.test", options.UserBindTemplate);
Assert.False(options.AcceptDomainQualifiedNames);
}
} }
@@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection
private bool _throwOnConnect; private bool _throwOnConnect;
private bool _throwOnServiceBind; private bool _throwOnServiceBind;
private bool _throwOnUserBind; private bool _throwOnUserBind;
private int? _firstBindResultCode;
private bool _throwOnSearch;
// ---- observation ----- // ---- observation -----
@@ -32,13 +34,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// </summary> /// </summary>
public int BindAttempts { get; private set; } public int BindAttempts { get; private set; }
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
public IReadOnlyList<string>? SearchAttributes { get; private set; }
/// <summary>Every call in order: <c>connect</c>, <c>bind:&lt;dn&gt;</c>, <c>search:&lt;filter&gt;</c>.</summary>
public List<string> Calls { get; } = new();
// ---- builder methods ----- // ---- builder methods -----
/// <summary> /// <summary>
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call. /// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>. /// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
/// </summary> /// </summary>
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null) public FakeLdapConnection WithUserEntry(
string dn,
string[] memberOf,
string? displayName = null,
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
{ {
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase) var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{ {
@@ -46,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection
}; };
if (displayName is not null) if (displayName is not null)
attrs["displayName"] = new[] { displayName }; attrs["displayName"] = new[] { displayName };
if (extraAttributes is not null)
{
foreach (var (name, values) in extraAttributes)
attrs[name] = values;
}
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs)); _scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
return this; return this;
@@ -83,6 +101,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
return this; return this;
} }
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> with <paramref name="resultCode"/> on the
/// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
/// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
/// </summary>
public FakeLdapConnection ThrowOnFirstBind(int resultCode)
{
_firstBindResultCode = resultCode;
return this;
}
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
public FakeLdapConnection ThrowOnSearch()
{
_throwOnSearch = true;
return this;
}
/// <summary> /// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to /// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
/// simulate an unreachable directory (infrastructure failure). /// simulate an unreachable directory (infrastructure failure).
@@ -120,6 +156,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
int timeoutMs, int timeoutMs,
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null) RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
{ {
Calls.Add("connect");
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs); ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
ConnectCertCallback = serverCertificateValidationCallback; ConnectCertCallback = serverCertificateValidationCallback;
if (_throwOnConnect) if (_throwOnConnect)
@@ -130,8 +167,12 @@ internal sealed class FakeLdapConnection : ILdapConnection
public void Bind(string dn, string password) public void Bind(string dn, string password)
{ {
BindAttempts++; BindAttempts++;
Calls.Add($"bind:{dn}");
var isServiceBind = BindAttempts == 1; var isServiceBind = BindAttempts == 1;
if (isServiceBind && _firstBindResultCode is { } code)
throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
if ((_throwOnServiceBind && isServiceBind) if ((_throwOnServiceBind && isServiceBind)
|| (_throwOnUserBind && !isServiceBind) || (_throwOnUserBind && !isServiceBind)
|| _throwBindDns.Contains(dn)) || _throwBindDns.Contains(dn))
@@ -147,7 +188,14 @@ internal sealed class FakeLdapConnection : ILdapConnection
string searchBase, string searchBase,
string filter, string filter,
IReadOnlyList<string> attributes) IReadOnlyList<string> attributes)
=> _scriptedEntries.AsReadOnly(); {
Calls.Add($"search:{filter}");
SearchAttributes = attributes;
if (_throwOnSearch)
throw new Novell.Directory.Ldap.LdapException(
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
return _scriptedEntries.AsReadOnly();
}
public void Dispose() { /* nothing to clean up */ } public void Dispose() { /* nothing to clean up */ }
} }
@@ -147,4 +147,45 @@ public class LdapAuthServiceTests
Assert.True(r.Succeeded); Assert.True(r.Succeeded);
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups); Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
} }
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
{
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("alice", r.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
{
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("ALICE", r.Username);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
var svc = new LdapAuthService(Opts(), factory);
var r = await svc.AuthenticateAsync("alice", password!, default);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
Assert.Empty(factory.Created);
}
} }
@@ -0,0 +1,222 @@
using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
/// <summary>
/// <see cref="LdapBindMode.User"/>: bind as the signing-in user, then read their own entry on the
/// same connection. The service account in <see cref="Opts"/> is deliberately populated so the
/// tests prove it is never used.
/// </summary>
public class LdapAuthServiceUserBindTests
{
private static LdapOptions Opts(params string[] fallbacks) => new()
{
Enabled = true,
Server = "dc1",
Port = 636,
Transport = LdapTransport.Ldaps,
SearchBase = "dc=x",
UserNameAttribute = "sAMAccountName",
DisplayNameAttribute = "displayName",
GroupAttribute = "memberOf",
BindMode = LdapBindMode.User,
UserBindTemplate = "{0}@corp.test",
ServiceAccountDn = "cn=svc,dc=x",
ServiceAccountPassword = "svcpw",
FallbackServers = fallbacks,
};
private static FakeLdapConnection Jane() => new FakeLdapConnection()
.WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
private static Task<LdapAuthResult> Login(LdapAuthService svc, string user, string pw = "pw") =>
svc.AuthenticateAsync(user, pw, CancellationToken.None);
[Fact]
public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
Assert.Equal("Jane Doe", result.DisplayName);
Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
var conn = Assert.Single(factory.Created);
Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls);
Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
}
[Theory]
[InlineData(@"NAM\jdoe")]
[InlineData("jdoe@corp.test")]
[InlineData(" jdoe ")]
public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed)
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), typed);
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns);
}
[Fact]
public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", "");
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Empty(factory.Created);
}
[Theory]
[InlineData("@x")]
[InlineData(@"a\b\c")]
[InlineData("a@b@c")]
public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed)
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), typed);
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Empty(factory.Created);
}
[Fact]
public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory);
var result = await Login(svc, @"NAM\jdoe");
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Empty(factory.Created);
}
[Fact]
public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback()
{
var factory = new SequenceLdapConnectionFactory(
Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong");
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Single(factory.Created);
}
[Fact]
public async Task NonCredentialBindError_FailsOver()
{
var factory = new SequenceLdapConnectionFactory(
Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal(2, factory.Created.Count);
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
}
[Fact]
public async Task ConnectFailure_FailsOver()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal(2, factory.Created.Count);
}
[Fact]
public async Task SearchFailure_FailsOver()
{
var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal(2, factory.Created.Count);
}
[Fact]
public async Task EveryEndpointDown_IsSystemSide()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
}
[Fact]
public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Single(factory.Created);
}
[Fact]
public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch());
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task NoGroups_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "JDOE");
Assert.True(result.Succeeded);
Assert.Equal("JDOE", result.Username);
}
[Fact]
public async Task Search_RequestsTheUserNameAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes);
}
[Fact]
public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory);
await Login(svc, "jdoe");
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
}
}
@@ -116,4 +116,38 @@ public class LdapOptionsValidatorTests
Assert.False(new LdapOptionsValidator() Assert.False(new LdapOptionsValidator()
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } }) .Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
.Failed); .Failed);
private static LdapOptions UserOpts() => Opts() with
{
BindMode = LdapBindMode.User,
UserBindTemplate = "{0}@corp.test",
ServiceAccountDn = "",
ServiceAccountPassword = "",
};
[Fact]
public void Defaults_KeepServiceAccountMode()
{
var o = new LdapOptions();
Assert.Equal(LdapBindMode.ServiceAccount, o.BindMode);
Assert.Equal("", o.UserBindTemplate);
Assert.True(o.AcceptDomainQualifiedNames);
}
[Fact]
public void Validator_UserMode_DoesNotRequireServiceAccount() =>
Assert.True(new LdapOptionsValidator().Validate(null, UserOpts()).Succeeded);
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("jdoe@corp.test")]
[InlineData("{0}{0}@corp.test")]
public void Validator_UserMode_RequiresExactlyOnePlaceholder(string template)
{
var result = new LdapOptionsValidator().Validate(null, UserOpts() with { UserBindTemplate = template });
Assert.True(result.Failed);
Assert.Contains(nameof(LdapOptions.UserBindTemplate), result.FailureMessage, StringComparison.Ordinal);
}
} }
@@ -0,0 +1,47 @@
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
public class LdapUserNameTests
{
[Theory]
[InlineData("jdoe")]
[InlineData(" jdoe ")]
[InlineData(@"NAM\jdoe")]
[InlineData("jdoe@corp.test")]
public void Accepting_ReducesToBareName(string input)
{
Assert.True(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
Assert.Equal("jdoe", name);
}
[Theory]
[InlineData(@"NAM\jdoe")]
[InlineData("jdoe@corp.test")]
public void Strict_RefusesQualifiedNames(string input) =>
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: false, out _));
[Fact]
public void Strict_AcceptsBareName()
{
Assert.True(LdapUserName.TryNormalize(" jdoe ", acceptDomainQualified: false, out var name));
Assert.Equal("jdoe", name);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("@x")]
[InlineData(@"\jdoe")]
[InlineData("jdoe@")]
[InlineData(@"NAM\")]
[InlineData(@"a\b\c")]
[InlineData("a@b@c")]
[InlineData(@"NAM\jdoe@corp.test")]
public void Unusable_IsRefused(string? input)
{
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
Assert.Equal("", name);
}
}
+9
View File
@@ -54,3 +54,12 @@ cd docker && docker compose up -d --build # local rigs → http://localhost:5320
# wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime: # wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime:
dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out> dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out>
``` ```
**2026-10-01 — BTDB Manager registered on all three dashboards.** `appsettings.Wonder.json` (vd03,
committed) gains one instance `wonder` → `http://127.0.0.1:9876` (management link
`http://10.220.157.247:9876/`); the QA overlay `E:\ApiInstall\Overview\app\appsettings.QA.json` (deployed
only, identical on vq03/vq04) gains `vq03`/`vq04` → `http://wonder-app-vq0N.zmr.zimmer.com:9876`. All
`HasActiveRole: false` (single unclustered Blazor app); `/health/ready` carries one `sql` entry (BT
reachable). The registry is read at startup only, so `ZbOverview` was restarted on each node (previous
files kept as `*.bak-20261001T02xxxx`). Verified: vd03 6/6 Up, vq03 and vq04 12/12 Up.
@@ -150,6 +150,21 @@
"TimeoutSeconds": 10 "TimeoutSeconds": 10
} }
] ]
},
{
"Name": "BTDB Manager",
"ManagementLabel": "Manager",
"Instances": [
{
// HasActiveRole false: a single, unclustered Blazor app — no active/standby role.
// /health/ready carries one "sql" entry (BT on the DEV SQL box reachable) and is 503
// whenever that database is not; /health/active is an empty check set.
"Name": "wonder",
"BaseUrl": "http://127.0.0.1:9876",
"ManagementUrl": "http://10.220.157.247:9876/",
"HasActiveRole": false
}
]
} }
] ]
} }
+21
View File
@@ -5,6 +5,27 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions are shared acr
package in the family (see `Directory.Build.props`) even when a given release only touches package in the family (see `Directory.Build.props`) even when a given release only touches
one of them. one of them.
## 0.6.3 — 2026-09-28
### Added
- **gRPC hub follower sweeps are observable.** `GrpcSecretSyncService` (package
`ZB.MOM.WW.Secrets.Replicator.Grpc`) now logs every sweep outcome instead of only the sweeps
that pull a change:
- `EventId 4` (Debug) — a sweep found nothing new; logged on every in-sync sweep.
- `EventId 5` (Information) — an in-sync heartbeat, at most once per
`SecretsGrpcHubClientOptions.InSyncLogInterval` (new, default 1 hour, validated positive), so
"the loop is alive and still agrees with the hub" is visible at the default log level without
a line every 30 seconds.
- `EventId 6` (Information) — the first successful sweep after one or more failures, naming
the failure count.
- **`ISecretsHubSyncStatus`** — a public, DI-registered read model of the follower loop:
`LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError`. Getters are synchronized
with the writer, so a health check or status page reads a consistent snapshot.
No behaviour change to syncing itself, and no wire or schema change. Only hub followers gain
anything from this release; other consumers can stay on 0.6.2.
## 0.6.2 — 2026-08-11 ## 0.6.2 — 2026-08-11
### Changed ### Changed
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.6.2</Version> <Version>0.6.3</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup> </PropertyGroup>
@@ -150,6 +150,15 @@ public static class SecretsGrpcHubClientExtensions
}); });
} }
// Singleton, and registered for BOTH the concrete type and the public interface: the
// service needs the concrete type (it calls the internal RecordSuccess/RecordFailure), and
// an application observing hub-sync health (a readiness check, a status page) needs only
// the read-only ISecretsHubSyncStatus. TryAddSingleton for the concrete type means the
// interface registration below resolves the SAME instance rather than constructing a second
// one, which would silently split "what the service records" from "what a consumer reads".
services.TryAddSingleton<SecretsHubSyncStatus>();
services.TryAddSingleton<ISecretsHubSyncStatus>(sp => sp.GetRequiredService<SecretsHubSyncStatus>());
services.AddHostedService<GrpcSecretSyncService>(); services.AddHostedService<GrpcSecretSyncService>();
return services; return services;
@@ -36,6 +36,13 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
private readonly SecretsGrpcHubClientOptions _options; private readonly SecretsGrpcHubClientOptions _options;
private readonly ILogger<GrpcSecretSyncService> _logger; private readonly ILogger<GrpcSecretSyncService> _logger;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
private readonly SecretsHubSyncStatus _status;
// Last time a row actually moved, and last time the in-sync heartbeat was logged — both
// instance state because they describe THIS service's observed history, not anything the hub
// or the local store can answer on demand.
private DateTimeOffset? _lastPullUtc;
private DateTimeOffset? _lastInSyncHeartbeatUtc;
/// <summary>Creates the sync service.</summary> /// <summary>Creates the sync service.</summary>
/// <param name="local">The node's local store — the only thing this service writes to.</param> /// <param name="local">The node's local store — the only thing this service writes to.</param>
@@ -44,13 +51,20 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
/// <param name="options">Hub-client options.</param> /// <param name="options">Hub-client options.</param>
/// <param name="logger">Logger for sweep outcomes and failures.</param> /// <param name="logger">Logger for sweep outcomes and failures.</param>
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param> /// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
/// <param name="status">
/// The sweep-outcome tracker this service updates. Optional so existing callers that construct
/// this type directly (tests, mainly) keep compiling; a real host resolves the same singleton
/// <c>AddZbSecretsGrpcHubClient</c> registers for <see cref="ISecretsHubSyncStatus"/>, so the two
/// stay the same instance.
/// </param>
public GrpcSecretSyncService( public GrpcSecretSyncService(
ISecretStore local, ISecretStore local,
ISecretsHubReader hub, ISecretsHubReader hub,
ISecretCacheInvalidator? cacheInvalidator, ISecretCacheInvalidator? cacheInvalidator,
IOptions<SecretsGrpcHubClientOptions> options, IOptions<SecretsGrpcHubClientOptions> options,
ILogger<GrpcSecretSyncService> logger, ILogger<GrpcSecretSyncService> logger,
TimeProvider? timeProvider = null) TimeProvider? timeProvider = null,
SecretsHubSyncStatus? status = null)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(local); ArgumentNullException.ThrowIfNull(local);
@@ -59,6 +73,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
_options = options.Value; _options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System; _timeProvider = timeProvider ?? TimeProvider.System;
_status = status ?? new SecretsHubSyncStatus();
_reconciler = new SecretReplicationReconciler( _reconciler = new SecretReplicationReconciler(
local, local,
cacheInvalidator, cacheInvalidator,
@@ -82,11 +97,22 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
} }
/// <summary> /// <summary>
/// Runs one pull-only reconcile against the hub. /// Runs one pull-only reconcile against the hub. Throws on any failure — an unreachable hub, a
/// rejected credential, a deadline — so a direct caller sees the raw outcome. The background
/// loop never calls this directly; it goes through <see cref="SweepAndRecordAsync"/>, which
/// shares this method's reconcile core but also records the outcome and logs it.
/// </summary> /// </summary>
/// <param name="ct">A token to cancel the sweep.</param> /// <param name="ct">A token to cancel the sweep.</param>
/// <returns>How many rows were pulled from the hub.</returns> /// <returns>How many rows were pulled from the hub.</returns>
internal async Task<int> SweepAsync(CancellationToken ct) internal async Task<int> SweepAsync(CancellationToken ct) =>
(await SweepCoreAsync(ct).ConfigureAwait(false)).Pulled;
/// <summary>
/// The reconcile core shared by <see cref="SweepAsync"/> and <see cref="SweepAndRecordAsync"/>:
/// fetches the hub manifest and reconciles the local store against it. Throws on failure —
/// callers that want failures recorded/logged instead do that around this call, not inside it.
/// </summary>
private async Task<(int Pulled, int ManifestCount)> SweepCoreAsync(CancellationToken ct)
{ {
IReadOnlyList<SecretManifestEntry> hubManifest = IReadOnlyList<SecretManifestEntry> hubManifest =
await _hub.GetManifestAsync(ct).ConfigureAwait(false); await _hub.GetManifestAsync(ct).ConfigureAwait(false);
@@ -97,20 +123,54 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
// it rather than re-deriving the diff here also keeps the batching: a cold-starting node's // it rather than re-deriving the diff here also keeps the batching: a cold-starting node's
// pull set is central's ENTIRE inventory, and an unchunked GetSecrets would exceed gRPC's // pull set is central's ENTIRE inventory, and an unchunked GetSecrets would exceed gRPC's
// 4 MB default message cap and then fail identically on every interval forever. // 4 MB default message cap and then fail identically on every interval forever.
return await _reconciler int pulled = await _reconciler
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct) .ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
.ConfigureAwait(false); .ConfigureAwait(false);
return (pulled, hubManifest.Count);
} }
private async Task SweepSafelyAsync(CancellationToken ct) private Task SweepSafelyAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
/// <summary>
/// Test-only entry point for the same outcome-recording sweep the background loop runs — never
/// throws, always updates <see cref="ISecretsHubSyncStatus"/>, and logs exactly as a real tick
/// would. Exists so a test can drive one sweep at a time without standing up the
/// <see cref="BackgroundService"/> host lifecycle.
/// </summary>
/// <param name="ct">A token to cancel the sweep.</param>
internal Task SweepForTestAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
private async Task SweepAndRecordAsync(CancellationToken ct)
{ {
DateTimeOffset now = _timeProvider.GetUtcNow();
try try
{ {
int pulled = await SweepAsync(ct).ConfigureAwait(false); (int pulled, int manifestCount) = await SweepCoreAsync(ct).ConfigureAwait(false);
int priorFailures = _status.RecordSuccess(now);
if (priorFailures > 0)
{
LogSweepRecovered(priorFailures);
}
if (pulled > 0) if (pulled > 0)
{ {
LogSweepConverged(pulled); LogSweepConverged(pulled);
_lastPullUtc = now;
}
else
{
LogSweepInSync(manifestCount);
if (_lastInSyncHeartbeatUtc is null ||
now - _lastInSyncHeartbeatUtc.Value >= _options.InSyncLogInterval)
{
LogSweepInSyncHeartbeat(manifestCount, _lastPullUtc);
_lastInSyncHeartbeatUtc = now;
}
} }
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) catch (OperationCanceledException) when (ct.IsCancellationRequested)
@@ -124,6 +184,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
// has the same correct response: keep serving the local store and try again. Letting any // has the same correct response: keep serving the local store and try again. Letting any
// of it escape would fault the BackgroundService and take the host down over a transient // of it escape would fault the BackgroundService and take the host down over a transient
// central outage, which is precisely the availability the pull-only topology exists for. // central outage, which is precisely the availability the pull-only topology exists for.
_status.RecordFailure(now, ex);
LogSweepFailed(ex); LogSweepFailed(ex);
} }
} }
@@ -145,4 +206,36 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
Level = LogLevel.Warning, Level = LogLevel.Warning,
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")] Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
private partial void LogRowDiscarded(Exception exception, string secretName); private partial void LogRowDiscarded(Exception exception, string secretName);
/// <summary>
/// Logged at Debug on every sweep that found nothing to pull — the full-cadence signal, for a
/// verbose log level, that the loop is alive and agrees with the hub even between the rare
/// Information-level heartbeats.
/// </summary>
[LoggerMessage(
EventId = 4,
Level = LogLevel.Debug,
Message = "Secrets hub sweep found nothing new; {Count} secret(s) already in sync.")]
private partial void LogSweepInSync(int count);
/// <summary>
/// Logged at Information at most once per <see cref="SecretsGrpcHubClientOptions.InSyncLogInterval"/>
/// — an in-sync sweep is otherwise Debug-only, so this is the periodic proof at the default log
/// level that the follower is still converged rather than silently stuck.
/// </summary>
[LoggerMessage(
EventId = 5,
Level = LogLevel.Information,
Message = "Secrets hub in sync ({Count} secret(s)); last change pulled {LastPullUtc}")]
private partial void LogSweepInSyncHeartbeat(int count, DateTimeOffset? lastPullUtc);
/// <summary>
/// Logged at Information the first time a sweep succeeds after one or more failures — the
/// recovery line that closes out whatever run of <see cref="LogSweepFailed"/> Warnings preceded it.
/// </summary>
[LoggerMessage(
EventId = 6,
Level = LogLevel.Information,
Message = "Secrets hub sync recovered after {Failures} failed sweep(s)")]
private partial void LogSweepRecovered(int failures);
} }
@@ -0,0 +1,112 @@
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
/// <summary>Point-in-time outcome of the hub follower's sweeps. Never carries secret material.</summary>
/// <remarks>
/// Registered as a singleton by <c>AddZbSecretsGrpcHubClient</c> alongside the background sweep
/// service, so a host can surface hub-sync health (a readiness check, a status page) without
/// scraping logs. Every field reflects the outcome of the most recently completed sweep only —
/// there is deliberately no history here, just "what happened last time".
/// </remarks>
public interface ISecretsHubSyncStatus
{
/// <summary>
/// When the most recent sweep finished, whether it succeeded or failed. <see langword="null"/>
/// before the first sweep has completed.
/// </summary>
DateTimeOffset? LastSweepUtc { get; }
/// <summary>
/// When the most recent <em>successful</em> sweep finished. <see langword="null"/> if no sweep
/// has ever succeeded.
/// </summary>
DateTimeOffset? LastSuccessUtc { get; }
/// <summary>
/// The number of sweeps that have failed in a row, ending at the most recent sweep. Reset to
/// zero the moment a sweep succeeds.
/// </summary>
int ConsecutiveFailures { get; }
/// <summary>
/// The message from the most recent sweep failure, or <see langword="null"/> if the most recent
/// sweep succeeded (or none has run yet). This is the exception's type and message only — never
/// a stack trace, and never secret material, since the sweep never has plaintext in scope.
/// </summary>
string? LastError { get; }
}
/// <summary>The mutable implementation the sync service writes to; consumers see only the interface.</summary>
/// <remarks>
/// Writes (<see cref="RecordSuccess"/>/<see cref="RecordFailure"/>) run under <see cref="_gate"/>,
/// and every getter takes the same lock rather than reading its backing field bare — a plain field
/// read races the writer with no synchronization otherwise, and a torn/half-written
/// <see cref="DateTimeOffset"/> (a multi-field struct) or an interleaving that pairs one write's
/// <c>LastSweepUtc</c> with another write's <c>LastError</c> is exactly the inconsistency a status
/// consumer must not see.
/// </remarks>
internal sealed class SecretsHubSyncStatus : ISecretsHubSyncStatus
{
private readonly object _gate = new();
private DateTimeOffset? _lastSweepUtc;
private DateTimeOffset? _lastSuccessUtc;
private int _consecutiveFailures;
private string? _lastError;
/// <inheritdoc/>
public DateTimeOffset? LastSweepUtc
{
get { lock (_gate) { return _lastSweepUtc; } }
}
/// <inheritdoc/>
public DateTimeOffset? LastSuccessUtc
{
get { lock (_gate) { return _lastSuccessUtc; } }
}
/// <inheritdoc/>
public int ConsecutiveFailures
{
get { lock (_gate) { return _consecutiveFailures; } }
}
/// <inheritdoc/>
public string? LastError
{
get { lock (_gate) { return _lastError; } }
}
/// <summary>
/// Records a successful sweep, resetting the failure streak.
/// </summary>
/// <param name="now">The instant the sweep completed.</param>
/// <returns>
/// The consecutive-failure count <em>before</em> this success — the caller uses a non-zero
/// result to decide whether the sweep is a recovery worth logging.
/// </returns>
internal int RecordSuccess(DateTimeOffset now)
{
lock (_gate)
{
int prior = _consecutiveFailures;
_lastSweepUtc = _lastSuccessUtc = now;
_consecutiveFailures = 0;
_lastError = null;
return prior;
}
}
/// <summary>Records a failed sweep, extending the failure streak.</summary>
/// <param name="now">The instant the sweep failed.</param>
/// <param name="ex">The exception the sweep failed with.</param>
internal void RecordFailure(DateTimeOffset now, Exception ex)
{
lock (_gate)
{
_lastSweepUtc = now;
_consecutiveFailures++;
_lastError = ex.GetType().Name + ": " + ex.Message;
}
}
}
@@ -77,6 +77,20 @@ public sealed class SecretsGrpcHubClientOptions
/// </summary> /// </summary>
public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30); public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// How often an in-sync sweep (nothing to pull) is allowed to log an Information-level
/// heartbeat. Defaults to one hour. Must be positive.
/// </summary>
/// <remarks>
/// A converged follower sweeps every <see cref="SyncInterval"/> — 30 seconds by default — and
/// finds nothing new almost every time. Logging that outcome at Information on every sweep
/// would drown the log; staying silent forever would make "the sweep loop is alive and the hub
/// still agrees with this node" unobservable between the rare sweeps that actually pull
/// something. Every sweep still logs at Debug regardless of this interval, so a verbose log
/// level shows the true cadence when it is needed.
/// </remarks>
public TimeSpan InSyncLogInterval { get; set; } = TimeSpan.FromHours(1);
/// <summary> /// <summary>
/// Throws if the options are unusable. Called by the DI extension at registration time so a /// Throws if the options are unusable. Called by the DI extension at registration time so a
/// misconfigured node fails at startup rather than on the first silent sweep failure. /// misconfigured node fails at startup rather than on the first silent sweep failure.
@@ -161,6 +175,13 @@ public sealed class SecretsGrpcHubClientOptions
$"gRPC secrets hub client CallDeadline must be positive " + $"gRPC secrets hub client CallDeadline must be positive " +
$"({sectionPath}:CallDeadline, was {CallDeadline})."); $"({sectionPath}:CallDeadline, was {CallDeadline}).");
} }
if (InSyncLogInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"gRPC secrets hub client InSyncLogInterval must be positive " +
$"({sectionPath}:InSyncLogInterval, was {InSyncLogInterval}).");
}
} }
/// <summary>The configuration section these options are bound from unless a caller overrides it.</summary> /// <summary>The configuration section these options are bound from unless a caller overrides it.</summary>
@@ -0,0 +1,250 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// Covers the hub follower's sweep-outcome observability: the Debug-per-sweep / Information-
/// heartbeat-once-per-interval pair for an in-sync sweep (EventId 4/5), the recovered line the
/// first success after a run of failures logs (EventId 6), and <see cref="ISecretsHubSyncStatus"/>
/// tracking the same outcomes.
/// </summary>
public sealed class GrpcSecretSyncServiceLoggingTests
{
[Fact]
public async Task InSyncSweep_LogsDebugEveryTime_AndInformationHeartbeatOncePerInterval()
{
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-09-28T00:00:00Z"));
(GrpcSecretSyncService svc, RecordingListLogger logs, _) = Harness.Create(
time, inSyncLogInterval: TimeSpan.FromHours(1), manifestUnchanged: true);
await svc.SweepForTestAsync(CancellationToken.None);
time.Advance(TimeSpan.FromMinutes(30));
await svc.SweepForTestAsync(CancellationToken.None);
time.Advance(TimeSpan.FromMinutes(31));
await svc.SweepForTestAsync(CancellationToken.None);
Assert.Equal(3, logs.Entries.Count(l => l.EventId.Id == 4 && l.Level == LogLevel.Debug));
Assert.Equal(2, logs.Entries.Count(l => l.EventId.Id == 5 && l.Level == LogLevel.Information));
}
[Fact]
public async Task FirstSuccessAfterFailures_LogsRecovered_AndStatusResets()
{
var time = new FakeTimeProvider();
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
Harness.Create(time, failFirst: 2);
await svc.SweepForTestAsync(default);
await svc.SweepForTestAsync(default);
Assert.Equal(2, status.ConsecutiveFailures);
await svc.SweepForTestAsync(default);
Assert.Contains(logs.Entries, l => l.EventId.Id == 6 && l.Message.Contains("after 2 failed"));
Assert.Equal(0, status.ConsecutiveFailures);
Assert.NotNull(status.LastSuccessUtc);
}
[Fact]
public async Task A_failed_sweep_never_logs_EventId6_and_status_records_the_error()
{
var time = new FakeTimeProvider();
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
Harness.Create(time, failFirst: 1);
await svc.SweepForTestAsync(default);
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
Assert.Equal(1, status.ConsecutiveFailures);
Assert.NotNull(status.LastError);
Assert.Null(status.LastSuccessUtc);
}
[Fact]
public async Task An_in_sync_sweep_never_logs_the_recovered_line()
{
var time = new FakeTimeProvider();
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
Harness.Create(time, manifestUnchanged: true);
await svc.SweepForTestAsync(default);
await svc.SweepForTestAsync(default);
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
Assert.Equal(0, status.ConsecutiveFailures);
Assert.NotNull(status.LastSweepUtc);
}
// Assembles a GrpcSecretSyncService wired to a controllable fake hub, a no-op local store (the
// scenarios here never actually apply a row — an empty hub manifest means the reconciler's pull
// set is always empty), a log-capturing logger, and the internal status tracker the service
// writes to — so a test can both drive sweeps and observe everything they produced.
private static class Harness
{
public static (GrpcSecretSyncService Service, RecordingListLogger Logs, ISecretsHubSyncStatus Status) Create(
FakeTimeProvider time,
TimeSpan? inSyncLogInterval = null,
bool manifestUnchanged = false,
int failFirst = 0)
{
var logger = new RecordingListLogger();
var status = new SecretsHubSyncStatus();
ISecretsHubReader hub = failFirst > 0
? new SequencedFailThenEmptyHubReader(failFirst)
: new EmptyHubReader();
IOptions<SecretsGrpcHubClientOptions> options = Options.Create(new SecretsGrpcHubClientOptions
{
Endpoint = "https://central:5001",
BearerToken = "unused-by-the-fake",
SyncInterval = TimeSpan.FromHours(1),
InSyncLogInterval = inSyncLogInterval ?? TimeSpan.FromHours(1),
});
// manifestUnchanged has no separate fake to select: both readers above always answer an
// empty (therefore unchanged, nothing-to-pull) manifest once they are done failing, so
// the parameter exists to make each call site self-documenting rather than to branch on.
var svc = new GrpcSecretSyncService(
new NoOpLocalStore(),
hub,
cacheInvalidator: null,
options,
logger,
time,
status);
return (svc, logger, status);
}
}
/// <summary>A hub with nothing in it — every sweep is in-sync with zero rows to pull.</summary>
private sealed class EmptyHubReader : ISecretsHubReader
{
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
IReadOnlyList<SecretName> names, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
}
/// <summary>
/// Fails the manifest call with a transport-shaped exception for the first
/// <paramref name="failuresBeforeSuccess"/> calls, then behaves exactly like
/// <see cref="EmptyHubReader"/> forever after.
/// </summary>
private sealed class SequencedFailThenEmptyHubReader(int failuresBeforeSuccess) : ISecretsHubReader
{
private int _remaining = failuresBeforeSuccess;
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
{
if (_remaining > 0)
{
_remaining--;
return Task.FromException<IReadOnlyList<SecretManifestEntry>>(
new InvalidOperationException("hub unreachable"));
}
return Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
}
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
IReadOnlyList<SecretName> names, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
}
/// <summary>
/// A local store that is never actually exercised by these scenarios (the hub manifest is
/// always empty, so the reconciler's pull set is always empty and none of these members are
/// called) beyond <see cref="GetManifestAsync"/>, which the reconciler always calls to compute
/// the pull set.
/// </summary>
private sealed class NoOpLocalStore : ISecretStore
{
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task UpsertAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
}
}
/// <summary>A clock a test advances by hand, so an hourly heartbeat can be tested without waiting an hour.</summary>
/// <remarks>
/// Hand-rolled rather than taken from <c>Microsoft.Extensions.TimeProvider.Testing</c>, matching
/// this project's existing convention (see <c>HubFakeTimeProvider</c> in
/// <c>SecretsHubAuthInterceptorTests.cs</c>) of not taking that package dependency for one small type.
/// </remarks>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
/// <summary>Creates a clock starting at the current wall-clock time.</summary>
public FakeTimeProvider() : this(DateTimeOffset.UtcNow)
{
}
/// <summary>Creates a clock starting at <paramref name="now"/>.</summary>
/// <param name="now">The instant the clock starts at.</param>
public FakeTimeProvider(DateTimeOffset now) => _now = now;
/// <inheritdoc/>
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the clock forward by <paramref name="delta"/>.</summary>
/// <param name="delta">The amount of time to advance.</param>
public void Advance(TimeSpan delta) => _now += delta;
}
/// <summary>Captures every log entry's level, event id, and formatted message, across all levels.</summary>
/// <remarks>
/// Distinct from the existing <c>RecordingLogger&lt;T&gt;</c> in <c>TestDoubles.cs</c>, which only
/// captures Information/Warning and neither type captures <see cref="EventId"/> — this suite needs
/// Debug-level entries and event-id assertions that the existing double does not support, and
/// widening it would risk the tests that already depend on its narrower shape.
/// </remarks>
internal sealed class RecordingListLogger : ILogger<GrpcSecretSyncService>
{
/// <summary>Every entry logged, in order.</summary>
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
/// <inheritdoc/>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc/>
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc/>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
Entries.Add((logLevel, eventId, formatter(state, exception)));
}
}
@@ -291,11 +291,16 @@ public sealed class GrpcSecretSyncServiceTests : IDisposable
} }
[Fact] [Fact]
public async Task A_quiet_sweep_logs_nothing_at_information() public async Task A_quiet_sweep_logs_the_in_sync_heartbeat_once_but_no_warning()
{ {
// A quiet (nothing-to-pull) sweep no longer logs NOTHING at Information: the first-ever
// in-sync sweep always logs the once-per-InSyncLogInterval heartbeat (EventId 5), because
// "never logged before" is one of the two conditions that fires it. See
// GrpcSecretSyncServiceLoggingTests for the interval-gated repeat behaviour on later sweeps.
// What a quiet sweep still never does is warn.
await RunOneStartupSweepAsync(() => _hub.ManifestCallCount > 0); await RunOneStartupSweepAsync(() => _hub.ManifestCallCount > 0);
Assert.Equal(0, _logger.InformationCount); Assert.Equal(1, _logger.InformationCount);
Assert.Equal(0, _logger.WarningCount); Assert.Equal(0, _logger.WarningCount);
} }
+32
View File
@@ -2,6 +2,38 @@
Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`. Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`.
## Published 0.5.0 (2026-11-04)
Published the tested ignitionoee backlog package to the shared Gitea feed on
2026-11-04. Independent download matched the reviewed artifact SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`.
The ignitionoee router deployed this package to CR on 2026-11-04. No other
consumer was deployed in this task. Do not repack or replace published 0.5.0.
Retains 0.4.2's `.rise` fill-mode fix. Adds the remaining shared application CSS
from ignitionoee (including `td.row-actions`), composable `TechButton.Class` and
`ButtonSize`, plus `ITreeRow`/stateless `TreeTable<TRow>` in `ZB.MOM.WW.Theme`.
Tree styles live once in `layout.css`, with arbitrary-depth indentation and
keyboard-focus styling. Caller-owned filtering/collapse behavior stays intact.
Test harness migrated to bUnit 2.7.2 with AngleSharp 1.5.0 to address the existing
NU1902 advisory without disabling audit. Runtime package dependencies are unchanged.
Local verification: Release build passed with zero warnings/errors; Release tests
passed 60 tests, none skipped. `build/pack.sh` produced the local 0.5.0 nupkg.
All 19 remaining ignitionoee app.css rule blocks are present verbatim in the
shared stylesheet; the Impeccable detector reported no findings on changed UI files.
Browser QA also exposed a pre-existing mobile shell bug: closed navigation hid
the page through Chromium's disclosure wrapper. The wrapper override now applies
at all widths, with an explicit mobile rule hiding only the rail and author
display rules for older browsers. Two regression tests cover CSS placement and
closed-shell component structure; computed mobile/desktop behavior was checked in
the disposable ignitionoee local browser fixture.
Browser QA confirmed the visibility fix and isolated an additional mobile
min-content sizing issue: bounding the direct page to 100% width/max-width with
border-box reduced the page/document from 1055px to the 390px viewport and kept
wide-table scrolling inside its wrapper. That measured constraint is included.
See README's 0.5.0 migration section; deployed consumer validation remains separate
from local source/package/browser verification.
## Index history (migrated from the scadaproj root CLAUDE.md) ## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.** > Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.4.1</Version> <Version>0.5.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+3 -1
View File
@@ -3,7 +3,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="bunit" Version="1.40.0" /> <PackageVersion Include="bunit" Version="2.7.2" />
<!-- Test DOM parser: direct pin avoids bUnit's vulnerable 1.2.0 minimum. -->
<PackageVersion Include="AngleSharp" Version="1.5.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
+51 -4
View File
@@ -65,7 +65,8 @@ server-side** before redirecting (open-redirect risk):
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. | | `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. |
| `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. | | `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. |
| `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. | | `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. |
| `TechButton` (`ButtonVariant`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. `ButtonVariant` enum is in `ZB.MOM.WW.Theme`. | | `TechButton` (`ButtonVariant`, `ButtonSize`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Size` (`Default`/`Small`/`Large`), `Class`, `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. Caller classes are composed with required variant/size classes. |
| `TreeTable<TRow>` (`ITreeRow`) | `Rows`*, `Head`*, `Row`*, `Label`*, `Collapsed`, `OnToggle` | Stateless preorder tree table. The caller filters visible rows and owns expansion state. |
| `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. | | `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. |
| `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. | | `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. |
@@ -80,7 +81,7 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers | | `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers |
| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing | | `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing, shared page/group headers, badges, row actions, info notices, payload blocks and tree-table styles |
**Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its **Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its
0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes 0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes
@@ -128,6 +129,52 @@ so a token-block edit re-themes the whole kit.
```bash ```bash
# from ZB.MOM.WW.Theme/ # from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
dotnet test # 38 bUnit tests dotnet test # 60 tests verified for 0.5.0
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg ./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.5.0.nupkg
``` ```
## 0.5.0 migration (published 2026-11-04)
The tested package was published to the shared Gitea feed on 2026-11-04.
An independent feed download exactly matched the reviewed artifact (SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`).
The ignitionoee router deployed this package to CR on 2026-11-04; other consumer
rollouts remain separate. It retains the 0.4.2 `.rise` animation
fill-mode fix. The published package is immutable; this source documentation
update does not repack or replace it.
The shell now keeps page content visible when mobile navigation is collapsed.
The outer disclosure's `::details-content` wrapper is dissolved at every width;
an explicit mobile closed-state selector hides only the rail. Author display
rules retain the page for older browsers without `::details-content` support.
The menu remains a native keyboard-accessible disclosure without custom JavaScript.
Mobile pages are constrained to the shell width so wide tables scroll inside their
table wrapper instead of widening the whole document.
The remaining ignitionoee `app.css` rules now ship in `layout.css`: page and section
headers/meta, group headings/meta, count badges, `.notice.info`, `.row-actions`,
**`td.row-actions`**, and `.check-data`. Consumers may delete matching local copies
after adopting this package. The element-qualified row-action selector must remain:
the bare class loses to `.data-table td` regardless of stylesheet load order.
Use `Size="ButtonSize.Small"` or `Size="ButtonSize.Large"` on `TechButton`.
`Class="my-action"` and an attribute dictionary's `class` value are composed with
the required button classes. A lowercase `class` supplied in Razor binds to `Class`
as normal Blazor parameter binding. Supplying both spellings as separate component
parameters is unnecessary; use `Class` plus `AdditionalAttributes` when merging two
sources. Explicit `Busy` and `Type` continue to control the underlying attributes.
Implement `ZB.MOM.WW.Theme.ITreeRow` on a consumer's row model (`Key`, `Depth`,
`HasChildren`). Feed `TreeTable<TRow>` visible rows in preorder, with each subtree
contiguous after its parent. `Head` supplies all header cells; `Row` supplies cells
after the component's name cell; `Label` supplies the name. `OnToggle` reports the
stable row key, and the caller supplies a new `Collapsed` state and filters `Rows`.
The component never filters or mutates caller state. Indentation supports arbitrary
depth through `--tt-depth`; styles ship once, with no per-instance stylesheet.
Leaf controls preserve label alignment and omit subtree ARIA attributes. Interactive
expand/collapse requires an interactive Blazor host and Bootstrap Icons for chevrons.
The test harness uses bUnit 2.7.2 and directly pins AngleSharp 1.5.0. This avoids
the vulnerable parser minimum ([GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg))
and the binary incompatibility between bUnit 1.x and the patched parser. These
dependencies belong to the test project; the Theme runtime package gains none.
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Theme;
/// <summary>Bootstrap size modifier for <c>TechButton</c>.</summary>
public enum ButtonSize
{
/// <summary>The theme's standard button size, without a Bootstrap size modifier.</summary>
Default,
/// <summary>Compact button, using <c>btn-sm</c>.</summary>
Small,
/// <summary>Large button, using <c>btn-lg</c>.</summary>
Large,
}
@@ -1,6 +1,6 @@
@namespace ZB.MOM.WW.Theme @namespace ZB.MOM.WW.Theme
@* Components/TechButton.razor *@ @* Components/TechButton.razor *@
<button @attributes="AdditionalAttributes" type="@Type" class="btn @VariantClass" disabled="@Busy"> <button @attributes="AdditionalAttributes" type="@Type" class="@ButtonClass" disabled="@Busy">
@if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> } @if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
@ChildContent @ChildContent
</button> </button>
@@ -12,6 +12,12 @@
/// </summary> /// </summary>
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary; [Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
/// <summary>Bootstrap size modifier. Defaults to the theme's standard button size.</summary>
[Parameter] public ButtonSize Size { get; set; }
/// <summary>Additional CSS classes, composed with the variant, size and unmatched class attribute.</summary>
[Parameter] public string? Class { get; set; }
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary> /// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
[Parameter] public string Type { get; set; } = "button"; [Parameter] public string Type { get; set; } = "button";
@@ -30,6 +36,14 @@
/// </summary> /// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string ButtonClass => string.Join(" ", new[]
{
"btn", VariantClass,
Size switch { ButtonSize.Small => "btn-sm", ButtonSize.Large => "btn-lg", _ => null },
Class,
AdditionalAttributes?.FirstOrDefault(pair => string.Equals(pair.Key, "class", StringComparison.OrdinalIgnoreCase)).Value?.ToString()
}.Where(value => !string.IsNullOrWhiteSpace(value)));
private string VariantClass => Variant switch private string VariantClass => Variant switch
{ {
ButtonVariant.Secondary => "btn-outline-secondary", ButtonVariant.Secondary => "btn-outline-secondary",
@@ -0,0 +1,88 @@
@namespace ZB.MOM.WW.Theme
@typeparam TRow where TRow : ITreeRow
@* Stateless preorder tree table. The caller filters Rows and owns collapse state.
Shared styles ship once in layout.css, including arbitrary-depth indentation. *@
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>@Head</tr>
</thead>
<tbody>
@foreach (TRow row in Rows)
{
<tr @key="row.Key" class="tt-d@(row.Depth)">
<td class="tt-name" style="@DepthStyle(row)">
<button type="button" class="@TwistClass(row)" disabled="@(!row.HasChildren)"
aria-expanded="@AriaExpanded(row)" aria-label="@TwistLabel(row)"
@onclick="() => OnToggle.InvokeAsync(row.Key)">
<i class="@ChevronClass(row)" aria-hidden="true"></i>
</button>
@Label(row)
</td>
@Row(row)
</tr>
}
</tbody>
</table>
</div>
@code {
/// <summary>
/// The rows to render, in order — already filtered through
/// the caller's visibility policy.
/// </summary>
/// <remarks>
/// The filtering is the caller's so that this component holds no state at all: the page owns
/// <see cref="Collapsed"/>, and what is on screen is a pure function of it.
/// </remarks>
[Parameter, EditorRequired] public IReadOnlyList<TRow> Rows { get; set; } = [];
/// <summary>
/// The header cells — every <c>&lt;th&gt;</c>, including the one over the name column. The
/// component supplies the <c>&lt;thead&gt;</c> and its <c>&lt;tr&gt;</c>.
/// </summary>
[Parameter, EditorRequired] public RenderFragment Head { get; set; } = builder => { };
/// <summary>
/// One row's cells <b>after</b> the name cell: the component renders the name column, the
/// caller renders the rest.
/// </summary>
[Parameter, EditorRequired] public RenderFragment<TRow> Row { get; set; } = _ => builder => { };
/// <summary>The text of a row's name cell, beside its twist.</summary>
[Parameter, EditorRequired] public Func<TRow, string> Label { get; set; } = _ => string.Empty;
/// <summary>
/// The keys of the collapsed rows — read only to draw each twist the right way round.
/// </summary>
/// <remarks>
/// It is the same set the caller filtered <see cref="Rows"/> with, so a row can be collapsed
/// and on screen (that is what a shut branch looks like) but never expanded and hidden.
/// </remarks>
[Parameter] public IReadOnlySet<string> Collapsed { get; set; } = new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// Raised with a row's <see cref="ITreeRow.Key"/> when its twist is clicked. The caller
/// toggles that key in its own collapse set; this component changes nothing itself.
/// </summary>
[Parameter] public EventCallback<string> OnToggle { get; set; }
private static string DepthStyle(TRow row)
=> "--tt-depth: " + Math.Max(0, row.Depth).ToString(System.Globalization.CultureInfo.InvariantCulture);
private bool IsCollapsed(TRow row) => Collapsed.Contains(row.Key);
private string TwistClass(TRow row) => row.HasChildren ? "tt-twist" : "tt-twist tt-leaf";
private string ChevronClass(TRow row) => IsCollapsed(row) ? "bi bi-chevron-right" : "bi bi-chevron-down";
// Null omits the attribute: a leaf's twist is decoration, and announcing it as a collapsed
// control would promise a subtree that is not there.
private string? AriaExpanded(TRow row)
=> !row.HasChildren ? null : IsCollapsed(row) ? "false" : "true";
private string? TwistLabel(TRow row)
=> !row.HasChildren ? null : (IsCollapsed(row) ? "Expand " : "Collapse ") + Label(row);
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.Theme;
/// <summary>A row in a preorder flat tree, with each subtree contiguous after its parent.</summary>
public interface ITreeRow
{
/// <summary>Stable identity, unique in the table and used for keyed rendering and collapse state.</summary>
string Key { get; }
/// <summary>Nesting depth, starting at zero for a root.</summary>
int Depth { get; }
/// <summary>Whether this row owns a subtree and offers an expand/collapse toggle.</summary>
bool HasChildren { get; }
}
@@ -11,10 +11,12 @@
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Theme</PackageId> <PackageId>ZB.MOM.WW.Theme</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description> <Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" /> <InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
@@ -1,5 +1,5 @@
/* ZB.MOM.WW.Theme — side-rail + login layout /* ZB.MOM.WW.Theme — side-rail + login layout
Tokens live in theme.css; this sheet carries only layout + the side rail. */ Tokens live in theme.css; this sheet carries application layout and shared controls. */
/* ── Bootstrap button sizing ───────────────────────────────────────────────── /* ── Bootstrap button sizing ─────────────────────────────────────────────────
Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against
@@ -45,6 +45,12 @@
--bs-btn-padding-y: .25rem; --bs-btn-padding-y: .25rem;
--bs-btn-padding-x: .6rem; --bs-btn-padding-x: .6rem;
} }
.btn-group-lg > .btn,
.btn-lg {
--bs-btn-font-size: 1rem;
--bs-btn-padding-y: .5rem;
--bs-btn-padding-x: 1rem;
}
/* ── App shell: side rail + page ─────────────────────────────────────────── */ /* ── App shell: side rail + page ─────────────────────────────────────────── */
/* The shell is a native <details> disclosure so the narrow-viewport hamburger /* The shell is a native <details> disclosure so the narrow-viewport hamburger
@@ -59,6 +65,21 @@
min-height: calc(100vh - 3.3rem); min-height: calc(100vh - 3.3rem);
} }
/* A closed shell must hide navigation only, never the page. Modern Chromium
wraps all non-summary children in ::details-content and hides that box while
closed. Dissolve it at EVERY width so both children retain the shell's flex
layout. The mobile closed-rail rule below owns navigation visibility. */
.app-shell::details-content {
display: contents;
content-visibility: visible;
}
/* Older browsers use a UA display rule on closed disclosure children instead
of ::details-content. Author display rules keep the page present there too;
an unsupported pseudo-element rule above is simply ignored. */
.app-shell > main.page { display: block; }
.app-shell > #theme-rail { display: block; }
.app-shell .page { .app-shell .page {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -72,9 +93,8 @@
} }
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; } .app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing /* The native <details> owns its open state; the mobile rule explicitly hides
extra is needed there. On lg+ we force the rail visible (see media query) only #theme-rail when closed. Bootstrap's d-lg-none hides the toggle on lg+. */
and keep the toggle hidden via Bootstrap's d-lg-none. */
/* ── Side rail ───────────────────────────────────────────────────────────── */ /* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail { .side-rail {
@@ -92,20 +112,6 @@
and force it shown regardless of the <details> open state (the hamburger and force it shown regardless of the <details> open state (the hamburger
toggle is hidden at this width). */ toggle is hidden at this width). */
@media (min-width: 992px) { @media (min-width: 992px) {
/* Chromium >=121 wraps a <details>'s content in a generated ::details-content
box that carries content-visibility:hidden while the <details> is closed.
Because our app-shell ships closed by default (SSR, no JS) and the toggle
is d-lg-none here, that wrapper would (a) hide the rail+page entirely on
lg+ and (b) sit between .app-shell and its rail/page children, collapsing
the flex-lg-row layout into a vertical stack. Dissolving the wrapper with
display:contents removes its box (so content-visibility no longer hides the
content AND rail/page become direct flex children of .app-shell again).
Browsers that don't support ::details-content treat this as an invalid
selector and drop the rule, falling back to the legacy force-show below. */
.app-shell::details-content {
display: contents;
}
#theme-rail { #theme-rail {
display: block; display: block;
position: sticky; position: sticky;
@@ -116,10 +122,16 @@
} }
} }
/* When the side rail is collapsed under <lg viewports (the <details> is closed) /* The disclosure toggles navigation while the page remains usable underneath. */
the native disclosure hides #theme-rail; when open, restore full width on
mobile so the rail spans the viewport. */
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.app-shell:not([open]) > #theme-rail { display: none; }
/* Native disclosure layout can otherwise size the page to a table's
min-content width. Bound the page so .table-wrap scrolls internally. */
.app-shell > .page {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.side-rail { .side-rail {
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
@@ -323,3 +335,79 @@
.login-body { padding: 1.4rem 1.1rem 1.25rem; } .login-body { padding: 1.4rem 1.1rem 1.25rem; }
.login-error { margin-bottom: 0.85rem; } .login-error { margin-bottom: 0.85rem; }
.field-error { font-size: 0.78rem; margin-top: 0.2rem; } .field-error { font-size: 0.78rem; margin-top: 0.2rem; }
/* Shared application presentation, upstreamed from ignitionoee in 0.5.0. */
/* ── Page header ─────────────────────────────────────────────────────────────
Title left, `.spacer` eating the slack, actions/meta pushed hard right. */
.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; }
.page-head h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; margin: 0; text-wrap: balance; }
.page-head .spacer { flex: 1; }
.page-meta { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-soft); }
/* ── Application sections ────────────────────────────────────────────────────
A rule-underlined h2 band; same spacer trick as the page head. */
.app-section { margin-bottom: 1.5rem; }
.app-head {
display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
padding-bottom: 0.4rem; border-bottom: 1px solid var(--rule-strong); margin-bottom: 0.75rem;
}
.app-head h2 { font-size: 1.02rem; font-weight: 600; margin: 0; }
.app-head .spacer { flex: 1; }
.app-meta { font-family: var(--mono); font-size: 0.76rem; color: var(--ink-faint); }
/* ── Group header (per-site issue groups) ────────────────────────────────── */
.group-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
.group-name { font-size: 0.74rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-soft); }
.group-meta { font-family: var(--mono); font-size: 0.74rem; color: var(--ink-faint); }
/* ── Per-group count badge ───────────────────────────────────────────────────
Red by default (it counts problems); `.warn` for the softer amber case. */
.count-badge {
display: inline-block; min-width: 1.4rem; text-align: center;
font-family: var(--mono); font-size: 0.72rem; font-weight: 600;
padding: 0.05rem 0.35rem; border-radius: 999px;
color: var(--bad); background: var(--bad-bg); border: 1px solid var(--bad-border);
}
.count-badge.warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
/* ── Inline notice, info variant ─────────────────────────────────────────────
Theme 0.3.1 ships `.notice` (amber) but no variants; `.info` re-tints it to
the on-palette blue wash. `--info-bg`/`--info-border` come from the theme. */
.notice.info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
/* ── Table row-action cell ─────────────────────────────────────────────────
The bare class is what the mockup ships and what a `<span class="row-actions">`
still needs. It is NOT enough on its own: every use in this app is a table
cell, and Theme 0.3.1's `.data-table th, .data-table td { text-align: left }`
is class+element (0-1-1) against this rule's bare class (0-1-0) — specificity
beats load order, so the alignment silently lost everywhere. Observed in a
browser on 2026-08-05 (every row-action cell left-aligned; `nowrap` applied,
`text-align` did not); the cell-level restatement below is the fix.
See docs/recipes/router-ui-render-validation.md, Task 1 Defect 2. */
.row-actions { text-align: right; white-space: nowrap; }
td.row-actions { text-align: right; }
/* ── Preformatted check/payload block ────────────────────────────────────── */
.check-data {
margin: 0.35rem 0.9rem 0.6rem; padding: 0.4rem 0.6rem; background: var(--zebra-bg);
border: 1px solid var(--rule); border-radius: 4px;
font-family: var(--mono); font-size: 0.72rem; color: var(--ink-soft); line-height: 1.6;
overflow-x: auto; white-space: nowrap;
}
.check-data b { color: var(--accent-deep); font-weight: 500; }
/* ── Tree table ──────────────────────────────────────────────────────────────
Name-cell depth is a number emitted by TreeTable; it adds to the table's
standard 0.8rem cell padding and works at every depth. */
.data-table td.tt-name {
font-weight: 600;
padding-left: calc(0.8rem + var(--tt-depth, 0) * 1.2rem);
}
.tt-twist {
background: none; border: none; padding: 0; margin-right: 0.45rem;
color: var(--ink-faint); font-size: 0.75rem; line-height: 1; cursor: pointer;
}
.tt-twist:hover { color: var(--ink); }
.tt-twist:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* A hidden leaf control preserves the width of its siblings' toggles. */
.tt-leaf { visibility: hidden; }
@@ -225,7 +225,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
Add .rise to top-level sections; stagger with inline animation-delay Add .rise to top-level sections; stagger with inline animation-delay
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */ (.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } @keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.4s ease both; } .rise { animation: rise 0.4s ease backwards; /* backwards: end keyframe equals natural style, preventing a containing-block trap for position:fixed descendants */ }
/* ════════════════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════════════════
COMPONENT LIBRARY COMPONENT LIBRARY
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class BrandBarTests : TestContext public class BrandBarTests : BunitContext
{ {
[Fact] [Fact]
public void Renders_product_with_default_mark() public void Renders_product_with_default_mark()
{ {
var cut = RenderComponent<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Contains("OtOpcUa", cut.Markup); Assert.Contains("OtOpcUa", cut.Markup);
Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo
} }
@@ -13,7 +13,7 @@ public class BrandBarTests : TestContext
[Fact] [Fact]
public void Custom_logo_replaces_default_mark() public void Custom_logo_replaces_default_mark()
{ {
var cut = RenderComponent<BrandBar>(p => p var cut = Render<BrandBar>(p => p
.Add(x => x.Product, "ScadaBridge") .Add(x => x.Product, "ScadaBridge")
.Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>")))); .Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
Assert.NotNull(cut.Find(".brand .logo")); Assert.NotNull(cut.Find(".brand .logo"));
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class CommonControlsTests : TestContext public class CommonControlsTests : BunitContext
{ {
[Theory] [Theory]
[InlineData(ButtonVariant.Primary, "btn-primary")] [InlineData(ButtonVariant.Primary, "btn-primary")]
@@ -9,7 +9,7 @@ public class CommonControlsTests : TestContext
[InlineData(ButtonVariant.Ghost, "btn-link")] [InlineData(ButtonVariant.Ghost, "btn-link")]
public void TechButton_maps_variant(ButtonVariant v, string cls) public void TechButton_maps_variant(ButtonVariant v, string cls)
{ {
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go")); var cut = Render<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
var btn = cut.Find("button"); var btn = cut.Find("button");
Assert.Contains("btn", btn.ClassList); Assert.Contains("btn", btn.ClassList);
Assert.Contains(cls, btn.ClassList); Assert.Contains(cls, btn.ClassList);
@@ -18,7 +18,7 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechButton_busy_disables_and_passes_through_attributes() public void TechButton_busy_disables_and_passes_through_attributes()
{ {
var cut = RenderComponent<TechButton>(p => p var cut = Render<TechButton>(p => p
.Add(x => x.Busy, true) .Add(x => x.Busy, true)
.AddUnmatched("id", "save") .AddUnmatched("id", "save")
.AddChildContent("Save")); .AddChildContent("Save"));
@@ -30,14 +30,14 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechButton_not_busy_is_not_disabled() public void TechButton_not_busy_is_not_disabled()
{ {
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go")); var cut = Render<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
Assert.False(cut.Find("button").HasAttribute("disabled")); Assert.False(cut.Find("button").HasAttribute("disabled"));
} }
[Fact] [Fact]
public void TechCard_renders_title_and_body() public void TechCard_renders_title_and_body()
{ {
var cut = RenderComponent<TechCard>(p => p var cut = Render<TechCard>(p => p
.Add(x => x.Title, "Drivers") .Add(x => x.Title, "Drivers")
.AddChildContent("<div class='b'>x</div>")); .AddChildContent("<div class='b'>x</div>"));
Assert.Contains("Drivers", cut.Find(".panel-head").TextContent); Assert.Contains("Drivers", cut.Find(".panel-head").TextContent);
@@ -47,7 +47,7 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechField_renders_label_hint_error() public void TechField_renders_label_hint_error()
{ {
var cut = RenderComponent<TechField>(p => p var cut = Render<TechField>(p => p
.Add(x => x.Label, "Name") .Add(x => x.Label, "Name")
.Add(x => x.Hint, "required") .Add(x => x.Hint, "required")
.Add(x => x.Error, "missing") .Add(x => x.Error, "missing")
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class LoginCardTests : TestContext public class LoginCardTests : BunitContext
{ {
[Fact] [Fact]
public void Posts_to_action_with_username_password_fields() public void Posts_to_action_with_username_password_fields()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Action, "/auth/login")); .Add(x => x.Action, "/auth/login"));
var form = cut.Find("form"); var form = cut.Find("form");
@@ -19,7 +19,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void ReturnUrl_renders_hidden_input() public void ReturnUrl_renders_hidden_input()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.ReturnUrl, "/clusters")); .Add(x => x.ReturnUrl, "/clusters"));
var hidden = cut.Find("input[name=returnUrl]"); var hidden = cut.Find("input[name=returnUrl]");
@@ -29,7 +29,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Error_renders_notice() public void Error_renders_notice()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Error, "Bad credentials")); .Add(x => x.Error, "Bad credentials"));
Assert.Contains("Bad credentials", cut.Find(".notice").TextContent); Assert.Contains("Bad credentials", cut.Find(".notice").TextContent);
@@ -38,7 +38,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void No_returnUrl_no_hidden_input() public void No_returnUrl_no_hidden_input()
{ {
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Empty(cut.FindAll("input[name=returnUrl]")); Assert.Empty(cut.FindAll("input[name=returnUrl]"));
} }
@@ -47,7 +47,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Product_is_wrapped_in_login_product_span() public void Product_is_wrapped_in_login_product_span()
{ {
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
var product = cut.Find(".login-title .login-product"); var product = cut.Find(".login-title .login-product");
Assert.Equal("OtOpcUa", product.TextContent); Assert.Equal("OtOpcUa", product.TextContent);
Assert.Contains("sign in", cut.Find(".login-title").TextContent); Assert.Contains("sign in", cut.Find(".login-title").TextContent);
@@ -57,7 +57,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Heading_overrides_default_heading_when_set() public void Heading_overrides_default_heading_when_set()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Heading, "Welcome back")); .Add(x => x.Heading, "Welcome back"));
var title = cut.Find(".login-title"); var title = cut.Find(".login-title");
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class NavRailTests : TestContext public class NavRailTests : BunitContext
{ {
[Fact] [Fact]
public void NavRailItem_renders_rail_link_with_href_and_text() public void NavRailItem_renders_rail_link_with_href_and_text()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")); .Add(x => x.Text, "Clusters"));
var a = cut.Find("a.rail-link"); var a = cut.Find("a.rail-link");
@@ -17,7 +17,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied() public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters") .Add(x => x.Text, "Clusters")
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>")))); .Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
@@ -29,7 +29,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailItem_omits_rail_ico_span_when_no_icon() public void NavRailItem_omits_rail_ico_span_when_no_icon()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")); .Add(x => x.Text, "Clusters"));
Assert.Empty(cut.FindAll(".rail-ico")); Assert.Empty(cut.FindAll(".rail-ico"));
@@ -38,7 +38,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_renders_title_and_children_open_by_default() public void NavRailSection_renders_title_and_children_open_by_default()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation") .Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
var details = cut.Find("details.rail-section"); var details = cut.Find("details.rail-section");
@@ -52,7 +52,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_summary_aria_expanded_true_when_open() public void NavRailSection_summary_aria_expanded_true_when_open()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation") .Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -61,7 +61,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_collapsed_when_not_expanded() public void NavRailSection_collapsed_when_not_expanded()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.False(cut.Find("details.rail-section").HasAttribute("open")); Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
@@ -71,7 +71,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_summary_aria_expanded_false_when_collapsed() public void NavRailSection_summary_aria_expanded_false_when_collapsed()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -80,7 +80,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default() public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Site Calls") .Add(x => x.Title, "Site Calls")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -89,7 +89,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_emits_explicit_key_when_supplied() public void NavRailSection_emits_explicit_key_when_supplied()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav") .Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -98,7 +98,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key() public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, " ") .Add(x => x.Title, " ")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -107,7 +107,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_slug_preserves_unicode_letters() public void NavRailSection_slug_preserves_unicode_letters()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Café") .Add(x => x.Title, "Café")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -39,6 +39,36 @@ public class StaticAssetsTests
public void NavStateScript_ships() => public void NavStateScript_ships() =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js"))); Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js")));
[Fact]
public void Shared_layout_ships_row_action_specificity_and_variable_size_modifiers()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
Assert.Matches(@"td\.row-actions\s*\{\s*text-align:\s*right", css);
foreach (var selector in new[] { ".page-head", ".page-meta", ".app-section", ".app-head",
".app-meta", ".group-head", ".group-name", ".group-meta", ".count-badge",
".count-badge.warn", ".notice.info", ".row-actions", ".check-data" })
Assert.Contains(selector, css);
Assert.Contains("var(--tt-depth, 0)", css);
Assert.Matches(@"\.btn-lg\s*\{\s*--bs-btn-font-size:\s*1rem", css);
Assert.Matches(@"\.tt-leaf\s*\{\s*visibility:\s*hidden", css);
}
[Fact]
public void Closed_mobile_shell_hides_only_the_rail_and_keeps_main_content_visible()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
css = Regex.Replace(css, @"/\*.*?\*/", "", RegexOptions.Singleline);
var baseRules = css[..css.IndexOf("@media", StringComparison.Ordinal)];
Assert.Matches(@"\.app-shell::details-content\s*\{[^}]*display:\s*contents", baseRules);
Assert.Matches(@"\.app-shell\s*>\s*main\.page\s*\{[^}]*display:\s*block", baseRules);
var mobile = css[css.IndexOf("@media (max-width: 991.98px)", StringComparison.Ordinal)..];
Assert.Matches(@"\.app-shell:not\(\[open\]\)\s*>\s*#theme-rail\s*\{\s*display:\s*none", mobile);
var page = Regex.Match(mobile, @"\.app-shell\s*>\s*\.page\s*\{([^}]*)\}").Groups[1].Value;
Assert.Matches(@"\bwidth:\s*100%", page);
Assert.Matches(@"\bmax-width:\s*100%", page);
Assert.Matches(@"\bbox-sizing:\s*border-box", page);
}
// Theme-002: .chip-idle pairs the idle background with the matching --idle // Theme-002: .chip-idle pairs the idle background with the matching --idle
// foreground token (per DESIGN-TOKENS.md), not --ink-soft. // foreground token (per DESIGN-TOKENS.md), not --ink-soft.
[Fact] [Fact]
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class StatusPillTests : TestContext public class StatusPillTests : BunitContext
{ {
[Theory] [Theory]
[InlineData(StatusState.Ok, "chip-ok")] [InlineData(StatusState.Ok, "chip-ok")]
@@ -10,7 +10,7 @@ public class StatusPillTests : TestContext
[InlineData(StatusState.Info, "chip-info")] [InlineData(StatusState.Info, "chip-info")]
public void Maps_state_to_chip_class(StatusState state, string expected) public void Maps_state_to_chip_class(StatusState state, string expected)
{ {
var cut = RenderComponent<StatusPill>(p => p var cut = Render<StatusPill>(p => p
.Add(x => x.State, state) .Add(x => x.State, state)
.AddChildContent("Connected")); .AddChildContent("Connected"));
var span = cut.Find("span"); var span = cut.Find("span");
@@ -0,0 +1,66 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TechButtonExtensionsTests : BunitContext
{
[Theory]
[InlineData(ButtonSize.Default, null)]
[InlineData(ButtonSize.Small, "btn-sm")]
[InlineData(ButtonSize.Large, "btn-lg")]
public void Size_preserves_variant_and_composes_both_class_inputs(ButtonSize size, string? expected)
{
var cut = Render<TechButton>(p => p
.Add(x => x.Variant, ButtonVariant.Secondary)
.Add(x => x.Size, size)
.Add(x => x.Class, "explicit-action")
.Add(x => x.AdditionalAttributes, new Dictionary<string, object>
{
["class"] = "caller-action", ["aria-label"] = "Save changes"
})
.AddChildContent("Save"));
var button = cut.Find("button");
foreach (var cls in new[] { "btn", "btn-outline-secondary", "explicit-action", "caller-action" })
Assert.Contains(cls, button.ClassList);
Assert.Equal("Save changes", button.GetAttribute("aria-label"));
if (expected is not null) Assert.Contains(expected, button.ClassList);
else
{
Assert.DoesNotContain("btn-sm", button.ClassList);
Assert.DoesNotContain("btn-lg", button.ClassList);
}
}
[Fact]
public void Class_composition_keeps_busy_type_and_click_behavior()
{
var clicks = 0;
var attributes = new Dictionary<string, object>
{
["class"] = "caller", ["disabled"] = false,
["onclick"] = EventCallback.Factory.Create(this, () => clicks++)
};
var cut = Render<TechButton>(p => p
.Add(x => x.Class, "extra")
.Add(x => x.Type, "submit")
.Add(x => x.Busy, true)
.Add(x => x.AdditionalAttributes, attributes)
.AddChildContent("Save"));
Assert.True(cut.Find("button").HasAttribute("disabled"));
Assert.Equal("submit", cut.Find("button").GetAttribute("type"));
Assert.NotNull(cut.Find(".spinner-border"));
Assert.Equal("caller", attributes["class"]);
cut.Render(p => p.Add(x => x.Busy, false));
cut.Find("button").Click();
Assert.Equal(1, clicks);
Assert.DoesNotContain(cut.FindAll("span"), x => x.ClassList.Contains("spinner-border"));
}
[Fact]
public void Lowercase_class_attribute_preserves_required_button_classes()
{
var cut = Render<TechButton>(p => p.AddUnmatched("class", "caller").AddChildContent("Go"));
foreach (var cls in new[] { "btn", "btn-primary", "caller" })
Assert.Contains(cls, cut.Find("button").ClassList);
}
}
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeHeadTests : TestContext public class ThemeHeadTests : BunitContext
{ {
[Fact] [Fact]
public void Emits_theme_and_layout_links_to_content_path() public void Emits_theme_and_layout_links_to_content_path()
{ {
var cut = RenderComponent<ThemeHead>(); var cut = Render<ThemeHead>();
var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList(); var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList();
Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs); Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs);
Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs); Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs);
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeScriptsTests : TestContext public class ThemeScriptsTests : BunitContext
{ {
[Fact] [Fact]
public void ThemeScripts_emits_nav_state_script_tag() public void ThemeScripts_emits_nav_state_script_tag()
{ {
var cut = RenderComponent<ThemeScripts>(); var cut = Render<ThemeScripts>();
var script = cut.Find("script"); var script = cut.Find("script");
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src")); Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
Assert.True(script.HasAttribute("defer")); Assert.True(script.HasAttribute("defer"));
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeShellTests : TestContext public class ThemeShellTests : BunitContext
{ {
[Fact] [Fact]
public void Renders_product_nav_and_body() public void Renders_product_nav_and_body()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>"))) .Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
.AddChildContent("<div class='pagebody'>BODY</div>")); .AddChildContent("<div class='pagebody'>BODY</div>"));
@@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void Accent_sets_css_variable_on_shell_root() public void Accent_sets_css_variable_on_shell_root()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "ScadaBridge") .Add(x => x.Product, "ScadaBridge")
.Add(x => x.Accent, "#2f855a")); .Add(x => x.Accent, "#2f855a"));
var shell = cut.Find(".app-shell"); var shell = cut.Find(".app-shell");
@@ -28,14 +28,14 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void No_accent_emits_no_style() public void No_accent_emits_no_style()
{ {
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway")); var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
Assert.False(cut.Find(".app-shell").HasAttribute("style")); Assert.False(cut.Find(".app-shell").HasAttribute("style"));
} }
[Fact] [Fact]
public void RailFooter_renders_when_supplied() public void RailFooter_renders_when_supplied()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>")))); .Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess")); Assert.NotNull(cut.Find(".rail-foot .sess"));
@@ -47,7 +47,7 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse() public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
{ {
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
// Root is a <details> whose direct <summary> is the hamburger toggle. // Root is a <details> whose direct <summary> is the hamburger toggle.
var shell = cut.Find("details.app-shell"); var shell = cut.Find("details.app-shell");
@@ -62,4 +62,18 @@ public class ThemeShellTests : TestContext
Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml); Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
Assert.DoesNotContain("data-bs-target", shell.OuterHtml); Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
} }
[Fact]
public void Closed_shell_keeps_page_body_separate_from_the_toggled_rail()
{
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OEE")
.Add(x => x.Nav, builder => builder.AddMarkupContent(0, "<a href='/oee'>OEE</a>"))
.AddChildContent("<h1>OEE report</h1><input aria-label='Filter' />"));
Assert.False(cut.Find("details.app-shell").HasAttribute("open"));
Assert.NotNull(cut.Find("details.app-shell > #theme-rail > nav"));
Assert.Equal("OEE report", cut.Find("details.app-shell > main.page > h1").TextContent);
Assert.NotNull(cut.Find("details.app-shell > main.page > input"));
Assert.Empty(cut.FindAll("#theme-rail main"));
}
} }
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TreeTableTests : BunitContext
{
private sealed record TestRow(string Key, int Depth, bool HasChildren, string Name) : ITreeRow;
private IRenderedComponent<TreeTable<TestRow>> RenderTreeTable(IReadOnlyList<TestRow> rows,
IReadOnlySet<string>? collapsed = null, Action<string>? toggle = null)
=> Render<TreeTable<TestRow>>(p => p
.Add(x => x.Rows, rows)
.Add(x => x.Collapsed, collapsed ?? new HashSet<string>())
.Add(x => x.Label, row => row.Name)
.Add(x => x.Head, builder => builder.AddMarkupContent(0, "<th>Scope</th><th>Value</th>"))
.Add(x => x.Row, row => builder =>
{
builder.OpenElement(0, "td");
builder.AddContent(1, "Value for " + row.Name);
builder.CloseElement();
})
.Add(x => x.OnToggle, key => toggle?.Invoke(key)));
[Fact]
public void Caller_fragments_and_arbitrary_depth_are_rendered_without_inline_stylesheets()
{
var cut = RenderTreeTable([new("root", 0, true, "Site"), new("deep", 7, false, "Machine <7>")]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Equal(2, cut.FindAll("tbody tr").Count);
Assert.Equal("Value for Machine <7>", cut.FindAll("tbody td")[3].TextContent);
Assert.Contains("Machine <7>", cut.Find(".tt-d7 .tt-name").TextContent);
Assert.Contains("--tt-depth: 7", cut.Find(".tt-d7 .tt-name").GetAttribute("style"));
Assert.Empty(cut.FindAll("style"));
}
[Fact]
public void Leaf_keeps_alignment_without_promising_a_subtree()
{
var cut = RenderTreeTable([new("leaf", 1, false, "Cell")]);
var twist = cut.Find("button");
Assert.Contains("tt-leaf", twist.ClassList);
Assert.True(twist.HasAttribute("disabled"));
Assert.False(twist.HasAttribute("aria-expanded"));
Assert.False(twist.HasAttribute("aria-label"));
Assert.Equal("true", cut.Find("i").GetAttribute("aria-hidden"));
}
[Fact]
public void Toggle_reports_stable_key_and_caller_controls_expansion()
{
string? toggled = null;
var cut = RenderTreeTable([new("site-key", 0, true, "Site")],
new HashSet<string> { "site-key" }, key => toggled = key);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Expand Site", cut.Find("button").GetAttribute("aria-label"));
cut.Find("button").Click();
Assert.Equal("site-key", toggled);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
cut.Render(p => p.Add(x => x.Collapsed, new HashSet<string>()));
Assert.Equal("true", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Collapse Site", cut.Find("button").GetAttribute("aria-label"));
Assert.Contains("bi-chevron-down", cut.Find("i").ClassList);
}
[Fact]
public void Empty_rows_keep_the_header_and_empty_body()
{
var cut = RenderTreeTable([]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Empty(cut.FindAll("tbody tr"));
}
}
@@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="bunit" /> <PackageReference Include="bunit" />
<PackageReference Include="AngleSharp" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
+2 -2
View File
@@ -380,7 +380,7 @@ each was established:
| 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) | | 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) |
| 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) | | 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) |
| 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) | | 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) |
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same 10-instance registry) | | 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same registry — 12 instances since 2026-10-01, BTDB Manager added) |
| 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) | | 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) |
| 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) | | 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) |
| **5985** | ✅ | ✅ | live | **WinRM** (HTTP/NTLM; 5986 refused) — **the supported access path since 2026-09-10** via the `winrm` skill; see §2.1 | | **5985** | ✅ | ✅ | live | **WinRM** (HTTP/NTLM; 5986 refused) — **the supported access path since 2026-09-10** via the `winrm` skill; see §2.1 |
@@ -399,7 +399,7 @@ nodes, verified Healthy at setup):
| ScadaBridge `site` | site-a — **Active** | site-b — Standby | | ScadaBridge `site` | site-a — **Active** | site-b — Standby |
| MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) | | MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) |
| HistorianGateway | Up | Up (since 2026-08-14) | | HistorianGateway | Up | Up (since 2026-08-14) |
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 10-instance registry) | | Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 12-instance registry) |
**Deployed builds on QA (2026-09-10 round, all via the `winrm` skill, both nodes):** ScadaBridge **Deployed builds on QA (2026-09-10 round, all via the `winrm` skill, both nodes):** ScadaBridge
`2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window, `2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window,