diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor
index 5f90d348..4b89dd6b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor
@@ -25,7 +25,19 @@
-
+ @* Gated on the page's OWN policy constant, not Roles="Administrator", so the rail
+ can never drift from what the page actually admits (pinned by SecretsNavLinkTests).
+ ManagePolicy resolves to AdminRole "Administrator" over the same ClaimTypes.Role
+ claim FleetAdmin reads.
+
+ NOTE this is currently the ONLY gated item, and the rail is therefore inconsistent:
+ /role-grants and /apikeys are FleetAdmin (Administrator-only) too and stay visible
+ to everyone, i.e. still advertised dead ends for a Designer/Operator. That is a
+ deliberate scope boundary, not an oversight — hiding them is a UX call for the
+ whole rail, filed as a follow-up rather than smuggled in here. *@
+
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsNavLinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsNavLinkTests.cs
new file mode 100644
index 00000000..d9327b08
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsNavLinkTests.cs
@@ -0,0 +1,107 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Authorization;
+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;
+
+///
+/// Pins the side-rail link to /admin/secrets and its role gate. The page has worked since
+/// lmxopcua#483, but reachability is a separate property from correctness: before this, the only
+/// way to the secrets UI was typing the URL, which is the same orphaned-surface shape as Gitea
+/// #546 () — everything builds, tests and deploys while no
+/// user can get there.
+///
+///
+/// Source scan rather than a rendering assertion: the repo has no bUnit (the central
+/// PackageVersion for it is referenced by no project), and razor layout markup — unlike
+/// a page's @page/@attribute directives — does not survive into class metadata
+/// any form a reflection guard can read. So this proves the link and its gate are *written*;
+/// that the gate actually admits an Administrator and hides the item from a Designer is
+/// live-verified on the docker-dev rig, per this repo's Razor convention.
+///
+[Trait("Category", "Unit")]
+public sealed class SecretsNavLinkTests
+{
+ private const string NavItem = "The reachability pin: without this the secrets UI is URL-only.
+ [Fact]
+ public void The_rail_links_to_the_secrets_page()
+ {
+ ReadMainLayoutSource().ShouldContain(
+ NavItem,
+ Case.Sensitive,
+ customMessage:
+ "the side rail no longer links to /admin/secrets — the secrets UI is reachable " +
+ "only by typing the URL, which is how it shipped before this test existed");
+ }
+
+ ///
+ /// The link must sit INSIDE the AuthorizeView, not merely somewhere in the same file — an
+ /// unnested pair would leave the item visible to every authenticated user while reading, to
+ /// a scan for both strings, exactly like a gated one. Ordering is the strongest structural
+ /// claim a text scan can make; whether the gate actually hides the item is live-verified.
+ ///
+ [Fact]
+ public void The_secrets_link_is_nested_inside_the_authorize_view()
+ {
+ var source = ReadMainLayoutSource();
+
+ var open = source.IndexOf("", open + 1, StringComparison.Ordinal);
+
+ open.ShouldBeGreaterThanOrEqualTo(0, "the rail has no policy-gated AuthorizeView at all");
+ close.ShouldBeGreaterThanOrEqualTo(0, "the gating AuthorizeView is never closed");
+ item.ShouldBeInRange(open, close,
+ "the /admin/secrets rail item is outside the AuthorizeView — it is visible to every " +
+ "authenticated user, and a scan that only looked for both tags would not notice");
+ }
+
+ ///
+ /// The gate must be expressed as the page's own policy constant. Roles="Administrator"
+ /// or a "secrets:manage" literal would render identically today and then silently drift the
+ /// moment the RCL changed its policy — the rail would advertise a page that rejects the user,
+ /// or hide one that would have admitted them, with nothing failing.
+ ///
+ [Fact]
+ public void The_gate_is_the_pages_own_policy_constant_not_a_role_or_a_literal()
+ {
+ var source = ReadMainLayoutSource();
+
+ source.ShouldContain("SecretsAuthorization.ManagePolicy", Case.Sensitive);
+ source.ShouldNotContain($"\"{SecretsAuthorization.ManagePolicy}\"", Case.Sensitive,
+ customMessage: "the rail hard-codes the policy string instead of referencing the constant");
+ source.ShouldNotContain("
+ /// Cross-check that the constant the rail references is the one the page actually enforces.
+ /// The two assertions above are satisfiable by a rail gated on a policy no page uses; this is
+ /// what makes them mean "the rail shows exactly who the page will admit".
+ ///
+ [Fact]
+ public void The_rails_policy_is_the_one_the_page_enforces()
+ {
+ var page = typeof(SecretsAdmin).GetCustomAttributes(inherit: false).Single();
+
+ page.Policy.ShouldBe(SecretsAuthorization.ManagePolicy,
+ "the rail gates on SecretsAuthorization.ManagePolicy, so the page must enforce it");
+ }
+}