Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f72ff851d | |||
| c878fbbd03 | |||
| 2254ae3dea |
@@ -132,10 +132,10 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.2.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.2.2" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.2" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.2" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.2.1" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.2.2" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
||||||
|
|||||||
@@ -47,6 +47,29 @@
|
|||||||
|
|
||||||
name: otopcua-dev
|
name: otopcua-dev
|
||||||
|
|
||||||
|
# ── Shared secret-replication env (all six host nodes) ──────────────────────────
|
||||||
|
# Akka peer-to-peer clustered secret replication rides the existing `otopcua` Akka
|
||||||
|
# mesh (DistributedPubSub topic `zb-mom-ww-secrets`) across all six host nodes. Two
|
||||||
|
# hard requirements are wired here, identically, on every node:
|
||||||
|
# 1. Secrets__Replication__Enabled=true → SecretsRegistration.AddOtOpcUaSecrets
|
||||||
|
# switches from plain AddZbSecrets to AddZbSecretsAkkaReplication + the eager
|
||||||
|
# SecretReplicationStarter hosted service, so every node joins anti-entropy.
|
||||||
|
# 2. ZB_SECRETS_MASTER_KEY → the ONE shared 32-byte base64 KEK. Every node MUST
|
||||||
|
# carry the SAME key or peers fail closed decrypting each other's rows
|
||||||
|
# (kek_id mismatch). appsettings.json binds Secrets:MasterKey:Source=Environment
|
||||||
|
# / EnvVarName=ZB_SECRETS_MASTER_KEY, so this env var IS the KEK on every node.
|
||||||
|
# The KEK below is a committed DEV-ONLY default (matches the rig's zero-operator-step
|
||||||
|
# philosophy, like the committed dev SQL password). Override for a fresh key with:
|
||||||
|
# OTOPCUA_SECRETS_KEK=$(openssl rand -base64 32) docker compose ... up -d
|
||||||
|
# NEVER reuse this key outside this local dev rig.
|
||||||
|
# AnnounceInterval is shortened to 5s (default 30s) so anti-entropy convergence is
|
||||||
|
# observable quickly during dev exercise. Each node keeps its own local SQLite store
|
||||||
|
# (Secrets:SqlitePath=otopcua-secrets.db, per-container); replication syncs them.
|
||||||
|
x-secrets-env: &secrets-env
|
||||||
|
Secrets__Replication__Enabled: "true"
|
||||||
|
Secrets__Replication__AnnounceInterval: "00:00:05"
|
||||||
|
ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
sql:
|
sql:
|
||||||
@@ -145,6 +168,7 @@ services:
|
|||||||
sql: { condition: service_healthy }
|
sql: { condition: service_healthy }
|
||||||
migrator: { condition: service_completed_successfully }
|
migrator: { condition: service_completed_successfully }
|
||||||
environment:
|
environment:
|
||||||
|
<<: *secrets-env
|
||||||
OTOPCUA_ROLES: "admin,driver"
|
OTOPCUA_ROLES: "admin,driver"
|
||||||
ASPNETCORE_URLS: "http://+:9000"
|
ASPNETCORE_URLS: "http://+:9000"
|
||||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||||
@@ -210,6 +234,7 @@ services:
|
|||||||
central-1: { condition: service_started }
|
central-1: { condition: service_started }
|
||||||
migrator: { condition: service_completed_successfully }
|
migrator: { condition: service_completed_successfully }
|
||||||
environment:
|
environment:
|
||||||
|
<<: *secrets-env
|
||||||
OTOPCUA_ROLES: "admin,driver"
|
OTOPCUA_ROLES: "admin,driver"
|
||||||
ASPNETCORE_URLS: "http://+:9000"
|
ASPNETCORE_URLS: "http://+:9000"
|
||||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||||
@@ -280,6 +305,7 @@ services:
|
|||||||
Cluster__PublicHostname: "site-a-1"
|
Cluster__PublicHostname: "site-a-1"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
|
<<: *secrets-env
|
||||||
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
|
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
|
||||||
# mem_reservation are inherited from the *otopcua-host anchor.
|
# mem_reservation are inherited from the *otopcua-host anchor.
|
||||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||||
@@ -304,6 +330,7 @@ services:
|
|||||||
Cluster__PublicHostname: "site-a-2"
|
Cluster__PublicHostname: "site-a-2"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
|
<<: *secrets-env
|
||||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
@@ -326,6 +353,7 @@ services:
|
|||||||
Cluster__PublicHostname: "site-b-1"
|
Cluster__PublicHostname: "site-b-1"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
|
<<: *secrets-env
|
||||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
@@ -346,6 +374,7 @@ services:
|
|||||||
Cluster__PublicHostname: "site-b-2"
|
Cluster__PublicHostname: "site-b-2"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
|
<<: *secrets-env
|
||||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
<HeadOutlet/>
|
<HeadOutlet/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })" />
|
@* No AdditionalAssemblies: the Secrets.Ui RCL's routable page is NOT routed directly in this
|
||||||
|
host — Pages/Secrets.razor wraps it so the route can carry this host's per-page
|
||||||
|
@rendermode (lmxopcua#483); registering the RCL routes too would make /admin/secrets
|
||||||
|
ambiguous. *@
|
||||||
|
<Routes />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<ThemeScripts />
|
<ThemeScripts />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@page "/admin/secrets"
|
||||||
|
@attribute [Authorize(Policy = ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy)]
|
||||||
|
@rendermode RenderMode.InteractiveServer
|
||||||
|
@using ZB.MOM.WW.Secrets.Ui
|
||||||
|
|
||||||
|
@* Host-side wrapper for the ZB.MOM.WW.Secrets.Ui secrets page (lmxopcua#483).
|
||||||
|
|
||||||
|
This host's <Routes/> is deliberately static (cookie SignInAsync needs SSR), so every
|
||||||
|
interactive page opts in per-page with @rendermode — a directive the RCL's own routable
|
||||||
|
SecretsPage cannot carry, because the other three family hosts render it under a globally
|
||||||
|
interactive router where a nested render mode throws. Routing straight to the RCL page
|
||||||
|
therefore produced a statically-rendered, dead page here: no circuit, @onclick inert.
|
||||||
|
|
||||||
|
So this wrapper owns the /admin/secrets route in THIS host (the RCL assembly is no longer
|
||||||
|
passed to either AdditionalAssemblies registration — doing both would make the route
|
||||||
|
ambiguous), carries the standard per-page render mode, re-states the RCL page's own
|
||||||
|
authorization policy (the router only enforces [Authorize] on the routed page itself,
|
||||||
|
which is now this one), and renders the RCL page as an ordinary child component. *@
|
||||||
|
|
||||||
|
<SecretsPage />
|
||||||
@@ -30,13 +30,14 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||||
// UseAuthentication() — see Program.cs.
|
// UseAuthentication() — see Program.cs.
|
||||||
// The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
|
// The Secrets.Ui RCL's routable page is deliberately NOT registered here (lmxopcua#483):
|
||||||
// routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
|
// this host's router is static with per-page @rendermode opt-in, so routing straight to
|
||||||
// the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
|
// the RCL page rendered it without a circuit — it displayed but every @onclick was dead.
|
||||||
// endpoint discovery. Both registrations are required or /admin/secrets 404s.
|
// Pages/Secrets.razor in THIS assembly owns /admin/secrets instead (wrapping the RCL
|
||||||
|
// page with the render mode); registering the RCL routes too would make the route
|
||||||
|
// ambiguous.
|
||||||
app.MapRazorComponents<TApp>()
|
app.MapRazorComponents<TApp>()
|
||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode();
|
||||||
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|||||||
/// unconditional.
|
/// unconditional.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Known ineffective against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0.</b> That
|
/// <b>History — this hook has met two library defects, both fixed.</b> Against 0.2.0 it was
|
||||||
/// version never binds its own <c>ISecretReplicator</c>:
|
/// inert: the package never bound its own <c>ISecretReplicator</c> (TryAdd registration
|
||||||
/// <c>AddZbSecretsAkkaReplication</c> calls <c>AddZbSecrets</c> first, which
|
/// order), so this resolve built a <c>ReplicatingSecretStore</c> around a no-op sink and
|
||||||
/// <c>TryAdd</c>s <c>NoOpSecretReplicator</c>, making the package's own subsequent
|
/// spawned no actor. Against 0.2.1 it was worse: the resolve <b>deadlocked the host at
|
||||||
/// <c>TryAddSingleton<ISecretReplicator></c> a no-op. Resolving the store therefore
|
/// startup</b> — the package's DI graph had a circular singleton dependency, invisible to
|
||||||
/// builds a <c>ReplicatingSecretStore</c> around a no-op replicator and spawns no actor.
|
/// the container through factory lambdas (scadaproj#1). Fixed in 0.2.2 by deferring the
|
||||||
/// This hook is correct and stays in place for when the library is fixed, but replication
|
/// cycle-closing invalidator edge;
|
||||||
/// must be treated as <b>non-functional</b> until then — see the skipped test
|
/// <c>SecretsReplicationRegistrationTests.The_startup_hook_actually_creates_the_replication_actor</c>
|
||||||
/// <c>SecretsReplicationRegistrationTests.The_startup_hook_actually_creates_the_replication_actor</c>.
|
/// exercises this hook against a built provider on a real single-node cluster.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="services">Root provider used to resolve the (decorated) secret store exactly once.</param>
|
/// <param name="services">Root provider used to resolve the (decorated) secret store exactly once.</param>
|
||||||
|
|||||||
+8
@@ -47,6 +47,11 @@ public class PageAuthorizationGuardTests
|
|||||||
// ── FleetAdmin (1) ──
|
// ── FleetAdmin (1) ──
|
||||||
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
|
||||||
|
|
||||||
|
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
|
||||||
|
// carries the RCL page's own policy, registered additively by AddAdminUI via
|
||||||
|
// AddSecretsAuthorization(). ──
|
||||||
|
[typeof(SecretsAdmin)] = global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
|
||||||
|
|
||||||
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
|
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
|
||||||
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
|
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
|
||||||
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
|
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
|
||||||
@@ -75,6 +80,9 @@ public class PageAuthorizationGuardTests
|
|||||||
AdminUiPolicies.DriverOperator,
|
AdminUiPolicies.DriverOperator,
|
||||||
AdminUiPolicies.ConfigEditor,
|
AdminUiPolicies.ConfigEditor,
|
||||||
AdminUiPolicies.AuthenticatedRead,
|
AdminUiPolicies.AuthenticatedRead,
|
||||||
|
// Not an AdminUiPolicies constant: the /admin/secrets wrapper re-states the Secrets.Ui
|
||||||
|
// RCL page's own policy, which AddAdminUI registers via AddSecretsAuthorization().
|
||||||
|
global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
|
||||||
];
|
];
|
||||||
|
|
||||||
private static IReadOnlyList<Type> RoutablePages() =>
|
private static IReadOnlyList<Type> RoutablePages() =>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
|
||||||
|
using ZB.MOM.WW.Secrets.Ui;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the host-side wiring that makes <c>/admin/secrets</c> interactive (lmxopcua#483).
|
||||||
|
/// This host's router is static with per-page <c>@rendermode</c> opt-in; routing straight to
|
||||||
|
/// the Secrets.Ui RCL's routable page rendered it with no circuit — the page displayed but
|
||||||
|
/// every <c>@onclick</c> was dead, and nothing failed. The fix is a wrapper page in this
|
||||||
|
/// assembly that owns the route and carries the render mode. Razor directives compile to
|
||||||
|
/// class-level attributes, so a metadata scan is authoritative (same technique as
|
||||||
|
/// <c>PageAuthorizationGuardTests</c>; the repo has no bUnit).
|
||||||
|
/// </summary>
|
||||||
|
public class SecretsPageWiringTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// THE #483 regression pin: without a render mode this host serves the page as static SSR
|
||||||
|
/// and it silently loses all interactivity.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Wrapper_declares_the_InteractiveServer_render_mode()
|
||||||
|
{
|
||||||
|
var mode = typeof(SecretsAdmin).GetCustomAttributes<RenderModeAttribute>(inherit: false)
|
||||||
|
.SingleOrDefault();
|
||||||
|
|
||||||
|
mode.ShouldNotBeNull(
|
||||||
|
"/admin/secrets lost its @rendermode — in this host's static router that renders a " +
|
||||||
|
"dead page (displays, but no circuit and every @onclick inert), exactly lmxopcua#483");
|
||||||
|
mode.Mode.ShouldBeOfType<InteractiveServerRenderMode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The wrapper must shadow the RCL page's route exactly — if Secrets.Ui ever moves its
|
||||||
|
/// page, the wrapper must move with it or the nav rail and bookmarks split from the RCL.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Wrapper_owns_the_same_route_as_the_RCL_page()
|
||||||
|
{
|
||||||
|
string[] wrapper = [.. typeof(SecretsAdmin).GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template)];
|
||||||
|
string[] rcl = [.. typeof(SecretsPage).GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template)];
|
||||||
|
|
||||||
|
wrapper.ShouldBe(["/admin/secrets"]);
|
||||||
|
wrapper.ShouldBe(rcl, "the wrapper must track the RCL page's route exactly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The router enforces [Authorize] only on the routed component — which is now the wrapper,
|
||||||
|
/// with the RCL page rendered as a plain child whose own attribute is inert. The wrapper
|
||||||
|
/// must therefore re-state the RCL page's policy verbatim, or the fix silently widens access.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Wrapper_restates_the_RCL_pages_authorization_policy()
|
||||||
|
{
|
||||||
|
var wrapper = typeof(SecretsAdmin).GetCustomAttributes<AuthorizeAttribute>(inherit: false).Single();
|
||||||
|
var rcl = typeof(SecretsPage).GetCustomAttributes<AuthorizeAttribute>(inherit: false).Single();
|
||||||
|
|
||||||
|
wrapper.Policy.ShouldBe(SecretsAuthorization.ManagePolicy);
|
||||||
|
wrapper.Policy.ShouldBe(rcl.Policy, "the wrapper must enforce exactly what the RCL page declares");
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-17
@@ -103,23 +103,13 @@ public sealed class SecretsReplicationRegistrationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Replication_enabled_decorates_the_store_so_writes_publish()
|
public void Replication_enabled_decorates_the_store_so_writes_publish()
|
||||||
{
|
{
|
||||||
// Asserted on the ServiceCollection rather than a built provider, deliberately. Resolving
|
// Asserted on the ServiceCollection because the registration order IS the defect class this
|
||||||
// ISecretStore with replication on constructs ReplicatingSecretStore, which resolves
|
// file guards (which descriptor won a TryAdd race), and the descriptor is where that is
|
||||||
// ISecretReplicator, which eagerly spawns the replication actor, whose PreStart calls
|
// visible. An earlier revision of this comment ALSO claimed a provider-based resolve was
|
||||||
// DistributedPubSub.Get(...) — unavailable until the node has joined a cluster. Against a
|
// impossible ("hangs against a plain ActorSystem — DistributedPubSub needs a joined
|
||||||
// plain ActorSystem that resolve hangs instead of failing.
|
// cluster"). That hang was real but misattributed: it was a circular singleton dependency
|
||||||
//
|
// in the 0.2.1 package's own DI wiring (scadaproj#1), fixed in 0.2.2. The provider-based
|
||||||
// Akka.TestKit is the family convention for this (ScadaBridge's suites use it throughout)
|
// resolve is covered by The_startup_hook_actually_creates_the_replication_actor below.
|
||||||
// and would be the right tool, but it is NOT available here: Akka.TestKit.Xunit2 is
|
|
||||||
// xunit-v2-only, and this project is one of the 44 on xunit.v3. Adding it yields CS0433
|
|
||||||
// type conflicts. Directory.Packages.props documents the same constraint — AdminUI.Tests,
|
|
||||||
// ControlPlane.Tests and Runtime.Tests are deliberately held on xunit v2 precisely because
|
|
||||||
// no xunit.v3 TestKit ships as of Akka 1.5.62. Host wiring tests belong here, not in one of
|
|
||||||
// those three, so the descriptor is the right seam until Akka ships a v3 TestKit.
|
|
||||||
//
|
|
||||||
// Nothing is lost: the registration order IS the defect, and the library's own
|
|
||||||
// TwoNodeClusterReplicationTests already cover actor creation and convergence against a
|
|
||||||
// genuine 2-node cluster.
|
|
||||||
//
|
//
|
||||||
// AddSingleton (not TryAdd) appends the decorator, and the LAST registration for a service
|
// AddSingleton (not TryAdd) appends the decorator, and the LAST registration for a service
|
||||||
// type is what the container resolves.
|
// type is what the container resolves.
|
||||||
@@ -130,6 +120,66 @@ public sealed class SecretsReplicationRegistrationTests
|
|||||||
"with replication enabled the local store must be decorated so writes publish to peers");
|
"with replication enabled the local store must be decorated so writes publish to peers");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_startup_hook_actually_creates_the_replication_actor()
|
||||||
|
{
|
||||||
|
// The test SecretReplicationStarter's docs have promised since the 0.2.x adoption, runnable
|
||||||
|
// now that the upstream deadlock is fixed: build the container the way the host does, start
|
||||||
|
// the hook, and prove the replication actor exists. On 0.2.0 this fails because the actor
|
||||||
|
// is never spawned (inert replicator); on 0.2.1 it deadlocks in the resolve (scadaproj#1).
|
||||||
|
//
|
||||||
|
// Akka.TestKit.Xunit2 is xunit-v2-only and this project is on xunit.v3 (CS0433), so this
|
||||||
|
// uses a plain self-joined single-node cluster — which is also all the actor needs: its
|
||||||
|
// constructor gets the DistributedPubSub mediator, no peers required.
|
||||||
|
ActorSystem system = ActorSystem.Create(
|
||||||
|
"otopcua-secrets-gate",
|
||||||
|
Akka.Configuration.ConfigurationFactory.ParseString("""
|
||||||
|
akka {
|
||||||
|
loglevel = WARNING
|
||||||
|
actor.provider = cluster
|
||||||
|
remote.dot-netty.tcp {
|
||||||
|
hostname = "127.0.0.1"
|
||||||
|
public-hostname = "127.0.0.1"
|
||||||
|
port = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cluster = Akka.Cluster.Cluster.Get(system);
|
||||||
|
cluster.Join(cluster.SelfAddress);
|
||||||
|
|
||||||
|
using ServiceProvider sp = BuildProvider(replicationEnabled: true, system);
|
||||||
|
|
||||||
|
// Startup order mirrors the host: hosted services run, nothing else has resolved the
|
||||||
|
// store yet. The hook's resolve is the moment 0.2.1 hung forever, so it runs under a
|
||||||
|
// watchdog — a regression should fail the test, not the whole run.
|
||||||
|
var starter = sp.GetServices<IHostedService>().OfType<SecretReplicationStarter>().Single();
|
||||||
|
|
||||||
|
Task start = starter.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
Task first = await Task.WhenAny(
|
||||||
|
start, Task.Delay(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken));
|
||||||
|
first.ShouldBe(start,
|
||||||
|
"resolving ISecretStore from the startup hook did not complete — the scadaproj#1 "
|
||||||
|
+ "DI deadlock has regressed");
|
||||||
|
await start;
|
||||||
|
|
||||||
|
// The store must be the replicating decorator, and the node's replication actor must
|
||||||
|
// genuinely exist under the configured name — not merely be registered.
|
||||||
|
sp.GetRequiredService<ISecretStore>().ShouldBeOfType<ReplicatingSecretStore>();
|
||||||
|
|
||||||
|
IActorRef actor = await system
|
||||||
|
.ActorSelection("/user/zb-secret-replication")
|
||||||
|
.ResolveOne(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||||
|
actor.Path.Name.ShouldBe("zb-secret-replication");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await system.Terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Replication_enabled_still_registers_the_undecorated_concrete_store()
|
public void Replication_enabled_still_registers_the_undecorated_concrete_store()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user