Compare commits
84 Commits
3c92bf54b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 967da5b43b | |||
| 6ad15fcf93 | |||
| 795af1da71 | |||
| 65c6b99cf2 | |||
| 420c2e0783 | |||
| 3cacdca861 | |||
| ade49c0935 | |||
| 02480d4fbc | |||
| 87bb647efb | |||
| a0cec479b6 | |||
| 91baba8fcc | |||
| 9688707d82 | |||
| 509114360d | |||
| cfd371ce18 | |||
| 51a956a878 | |||
| 2a3baae301 | |||
| 10fc22b87e | |||
| 8825cd966f | |||
| 69a7f8e1b5 | |||
| 01a6313fe7 | |||
| e7cfa21fda | |||
| 68f1b56acf | |||
| b4da0deacb | |||
| 180403cb17 | |||
| 67c182ce18 | |||
| 05042e5e36 | |||
| aed5fbf046 | |||
| 00ef22823f | |||
| c05a6d93b0 | |||
| ec2615a9d2 | |||
| 2a0b34e056 | |||
| 4555a3bdad | |||
| 681606dad0 | |||
| aa7093b53b | |||
| 17cb91a315 | |||
| 0adfb244b2 | |||
| c6e60cb6c4 | |||
| c713925ac7 | |||
| 0b056c6fee | |||
| 484b176cd6 | |||
| dd1eea2106 | |||
| 63cc772e8d | |||
| 3b4d81e630 | |||
| b6f5eb2535 | |||
| a8f5f66647 | |||
| df21d365ec | |||
| a2b40b7922 | |||
| 3c91f0f813 | |||
| a6c4161719 | |||
| 78ca2a6744 | |||
| 21a95c7f50 | |||
| c43d7ee1ad | |||
| d0b66bf105 | |||
| 44421dc5aa | |||
| 8fa7631ec4 | |||
| b62cfad48d | |||
| 625f207e08 | |||
| 29bfa8fbf9 | |||
| b0582eb66a | |||
| 9090b1b511 | |||
| e3d146aa21 | |||
| 177881c423 | |||
| 3c42069673 | |||
| 00ac436ae2 | |||
| 302d1245cd | |||
| fa0ca66a05 | |||
| 711e8beb2b | |||
| 9ac6ed51d3 | |||
| efd5d881dc | |||
| 0793159cbb | |||
| 51b6f1ab9f | |||
| 29d0c50f07 | |||
| adbf2eb182 | |||
| b392b84f21 | |||
| 5c73be486d | |||
| 308742b7a6 | |||
| 8cfe076012 | |||
| 646af887d1 | |||
| 1e0bfe03d4 | |||
| 9934bea05a | |||
| d0079ba1a1 | |||
| 4dda768801 | |||
| f6eef9d8eb | |||
| 16a84f9df4 |
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -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:<dn></c>, <c>search:<filter></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+9
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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>
|
||||||
|
|||||||
+250
@@ -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<T></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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
@@ -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,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" />
|
||||||
|
|||||||
@@ -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><th></c>, including the one over the name column. The
|
||||||
|
/// component supplies the <c><thead></c> and its <c><tr></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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user