diff --git a/backend/src/__tests__/secrets.test.ts b/backend/src/__tests__/secrets.test.ts index 3e9735cf..f41b2269 100644 --- a/backend/src/__tests__/secrets.test.ts +++ b/backend/src/__tests__/secrets.test.ts @@ -5,7 +5,7 @@ * - encrypt round-trip via CryptoService * - DatabaseService secret + version + push CRUD * - SecretsService versioning, importFromStack, executePush aggregation - * - Route guards (requirePaid 403, requireAdmin 403, requireUserSession 403, push lock 409) + * - Route guards (requireAdmin 403, requireUserSession 403, push lock 409) * - Hub-only enforcement is covered in hub-only-guard.test.ts * - developer_mode diagnostics gating (and that diagnostics never log the secret value) * - getAuditSummary patterns for /secrets routes @@ -74,9 +74,6 @@ beforeAll(async () => { ({ SecretsService } = await import('../services/SecretsService')); ({ CryptoService } = await import('../services/CryptoService')); - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); - ({ app } = await import('../index')); }); @@ -387,34 +384,12 @@ describe('getAuditSummary for secrets routes', () => { // ---- Route guards via supertest ---- -describe('Routes /api/secrets tier gating and lock', () => { - it('returns 403 when license is community', async () => { - const { LicenseService } = await import('../services/LicenseService'); - // Use mockReturnValueOnce so the outer beforeAll spy keeps returning 'paid' for sibling tests. - // requirePaid only consults getTier once per request via effectiveTier(req). - const inst = LicenseService.getInstance(); - const tierSpy = vi.spyOn(inst, 'getTier'); - tierSpy.mockReturnValueOnce('community'); - const res = await request(app) - .get('/api/secrets') - .set('Authorization', `Bearer ${adminToken()}`); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); - }); - +describe('Routes /api/secrets basic guards', () => { it('rejects unauthenticated requests', async () => { const res = await request(app).get('/api/secrets'); expect(res.status).toBe(401); }); - it('returns 200 when paid', async () => { - const res = await request(app) - .get('/api/secrets') - .set('Authorization', `Bearer ${adminToken()}`); - expect(res.status).toBe(200); - expect(Array.isArray(res.body)).toBe(true); - }); - it('rejects malformed body on POST /secrets', async () => { const res = await request(app) .post('/api/secrets') @@ -424,6 +399,114 @@ describe('Routes /api/secrets tier gating and lock', () => { }); }); +// ---- Community Admin happy path: Fleet Secrets is available without a paid license ---- + +describe('Routes /api/secrets Community Admin access', () => { + it('lets a Community admin list bundles', async () => { + const res = await request(app) + .get('/api/secrets') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('lets a Community admin create, read, update, and delete a bundle', async () => { + // Create + const create = await request(app) + .post('/api/secrets') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ name: 'community-test-bundle', kv: { KEY: 'val' } }); + expect(create.status).toBe(201); + const id: number = create.body.id; + // Read + const get = await request(app) + .get(`/api/secrets/${id}`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(get.status).toBe(200); + expect(get.body.kv).toEqual({ KEY: 'val' }); + // Update + const upd = await request(app) + .put(`/api/secrets/${id}`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ kv: { KEY: 'updated' } }); + expect(upd.status).toBe(200); + // Delete + const del = await request(app) + .delete(`/api/secrets/${id}`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(del.status).toBe(200); + }); + + it('lets a Community admin list versions', async () => { + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'versions-community', kv: { X: '1' }, user: TEST_USERNAME }); + const res = await request(app) + .get(`/api/secrets/${id}/versions`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + }); + + it('lets a Community admin import from a stack over HTTP', async () => { + const composeDir = process.env.COMPOSE_DIR!; + const stackDir = path.join(composeDir, 'importstack'); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, '.env'), 'IMPORT_KEY=hello\n'); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n'); + + const db = DatabaseService.getInstance(); + const localNode = db.getNodes().find(n => n.type === 'local')!; + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'import-http', kv: { X: '1' }, user: TEST_USERNAME }); + + const res = await request(app) + .post(`/api/secrets/${id}/import-from-stack`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ nodeId: localNode.id, stackName: 'importstack', envFileBasename: '.env' }); + expect(res.status).toBe(200); + expect(res.body.kv).toEqual({ IMPORT_KEY: 'hello' }); + }); + + it('lets a Community admin preview and execute a push over HTTP', async () => { + const composeDir = process.env.COMPOSE_DIR!; + const stackDir = path.join(composeDir, 'pushstack'); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\n'); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n'); + + const db = DatabaseService.getInstance(); + const localNode = db.getNodes().find(n => n.type === 'local')!; + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'push-http', kv: { EXISTING: 'updated', NEWKEY: 'added' }, user: TEST_USERNAME }); + + // Preview + const preview = await request(app) + .post(`/api/secrets/${id}/push/preview`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' }); + expect(preview.status).toBe(200); + expect(Array.isArray(preview.body)).toBe(true); + expect(preview.body.length).toBeGreaterThanOrEqual(1); + expect(preview.body[0].reachable).toBe(true); + + // Execute push + const push = await request(app) + .post(`/api/secrets/${id}/push`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' }); + expect(push.status).toBe(200); + expect(push.body.pushId).toBeTruthy(); + expect(push.body.results).toHaveLength(1); + expect(push.body.results[0].status).toBe('ok'); + + // Verify the .env was actually written + const envText = fs.readFileSync(path.join(composeDir, 'pushstack', '.env'), 'utf-8'); + const kv = parseEnv(envText); + expect(kv.EXISTING).toBe('updated'); + expect(kv.NEWKEY).toBe('added'); + }); +}); + // ---- Admin-role gating: secrets reveal decrypted values, so every route is admin-only ---- describe('Routes /api/secrets admin-role gating', () => { @@ -439,18 +522,26 @@ describe('Routes /api/secrets admin-role gating', () => { return authToken('sec-viewer', 'viewer', user.token_version); } - it.each(SECRET_ENDPOINTS)('403s a non-admin paid user on %s %s', async (method, p) => { + it.each(SECRET_ENDPOINTS)('403s a non-admin user on %s %s', async (method, p) => { const res = await callWithToken(method, p, viewerToken()); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIN_REQUIRED'); }); - it('lets an admin paid user list (200)', async () => { + it('lets an admin user list (200)', async () => { const res = await request(app) .get('/api/secrets') .set('Authorization', `Bearer ${adminToken()}`); expect(res.status).toBe(200); }); + + it('403s a Community viewer on all endpoints', async () => { + for (const [method, p] of SECRET_ENDPOINTS) { + const res = await callWithToken(method, p, viewerToken()); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + } + }); }); // ---- Machine-credential rejection: secrets need a real signed-in user session ---- diff --git a/backend/src/routes/secrets.ts b/backend/src/routes/secrets.ts index 78c839f1..d7ec31c1 100644 --- a/backend/src/routes/secrets.ts +++ b/backend/src/routes/secrets.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requirePaid, requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates'; +import { requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates'; import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService'; import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService'; import { isValidStackName } from '../utils/validation'; @@ -66,7 +66,6 @@ function parsePushBody(body: unknown): PushBody | { error: string } { secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const items = SecretsService.getInstance().list(); @@ -79,7 +78,6 @@ secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Prom secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -121,7 +119,6 @@ secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pro secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'secret ID'); @@ -141,7 +138,6 @@ secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): P secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -181,7 +177,6 @@ secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): P secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'secret ID'); @@ -201,7 +196,6 @@ secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response) secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'secret ID'); @@ -219,7 +213,6 @@ secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Res secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -249,7 +242,6 @@ secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { @@ -274,7 +266,6 @@ secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireUserSession(req, res)) return; - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { diff --git a/docs/features/fleet-secrets.mdx b/docs/features/fleet-secrets.mdx index 849a2a0a..6af9ce80 100644 --- a/docs/features/fleet-secrets.mdx +++ b/docs/features/fleet-secrets.mdx @@ -12,7 +12,7 @@ The unit of work is the **bundle**. One bundle has one current `kv` payload; pus -Fleet Secrets is a limited-availability surface. When it is present on an instance, managing it requires an admin user role. +Fleet Secrets is available on every Sencho installation. Managing bundles requires an admin user role. ## What Fleet Secrets covers (and what it doesn't) @@ -38,14 +38,14 @@ A **push** is a separate action. It reads the bundle's current version, walks ev | Requirement | Why it matters | |---|---| -| Admin role on the control instance | Bundle CRUD and push require an administrator when the surface is present; authored-by rows are written into the audit log | +| Admin role on the control instance | Bundle CRUD and push require an administrator; authored-by rows are written into the audit log | | At least one stack on at least one node | Pushes target an existing stack directory; the wizard does not create stacks | | The target stack's compose declares the env file via `env_file:` | The env-file dropdown in the push wizard reads `env_file:` entries from a representative node's compose; a stack with only an inline `environment:` block will not show up | | The control instance can reach the remote node's API URL | Each remote write is an HTTP call from the control instance to the remote's `/api/stacks/.../env`; an unreachable remote is reported as a per-node failure, not a transport error for the whole push | ## Create a bundle -1. Open **Fleet → Secrets** (when that tab is available on the instance). +1. Open **Fleet → Secrets** on the Fleet view. 2. Click **New bundle**. 3. Give it a name. Names are 2-64 characters, alphanumerics plus space, dot, dash, and underscore, and must start and end with an alphanumeric. 4. Optionally add a description; the description is a free-text field and is shown in the bundle list. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 448ecfb0..8f4df16d 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -37,7 +37,7 @@ A single rail summarises the state of every registered node so you can read the ### Tabs -The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Labels, Deployments, Federation, and Actions. Snapshots appears for admins. Routing and Secrets are limited-availability fleet surfaces and are not part of the default tab strip. A vertical separator after **Docker Labels** (or after **Map** when Docker Labels is not present) divides the per-node monitoring tabs from the fleet-wide orchestration tabs. +The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Labels, Deployments, Federation, and Actions. Snapshots appears for admins. Secrets appears for admins. Routing is a limited-availability fleet surface and is not part of the default tab strip. A vertical separator after **Docker Labels** (or after **Map** when Docker Labels is not present) divides the per-node monitoring tabs from the fleet-wide orchestration tabs. | Tab | Tier | What it does | |-----|------|--------------| @@ -50,7 +50,7 @@ The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Lab | **Routing** | Limited availability | Cross-node service routing via Sencho Mesh when that surface is enabled on the instance. See [Sencho Mesh](/features/sencho-mesh). | | **Federation** | Community | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). | | **Actions** | Community (admin role) | Fleet-wide bulk operations: stop stacks by label, bulk-assign labels, prune Docker resources. See [Fleet Actions](/features/fleet-actions). | -| **Secrets** | Limited availability | Encrypted env-var bundles you push to labeled nodes when that surface is enabled on the instance. See [Fleet Secrets](/features/fleet-secrets). | +| **Secrets** | Community (admin role) | Encrypted env-var bundles you push to labeled nodes across the fleet. See [Fleet Secrets](/features/fleet-secrets). | ### Action buttons diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 5243ae97..874e8809 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -194,7 +194,7 @@ When several Sencho instances run as a fleet, the control instance is the source ### Fleet Secrets -Centralized, encrypted, versioned env-var bundles you push to labeled nodes' stacks. Every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Limited-availability surface when present; admin role required to manage. [Learn more →](/features/fleet-secrets) +Centralized, encrypted, versioned env-var bundles you push to labeled nodes' stacks. Every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Available on every installation; admin role required to manage. [Learn more →](/features/fleet-secrets) ### Fleet-wide backups diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 5793fc79..4d7f88ec 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -21,7 +21,7 @@ These are the main terms you will see across the app and docs: - **Resource**: A Docker image, volume, network, or unmanaged container. Sencho classifies resources as managed, external, system, unused, or protected so cleanup decisions are visible. - **Blueprint**: A fleet deployment model for keeping a compose template assigned to matching nodes. - **Pilot Agent**: An outbound connector for remote nodes that cannot accept inbound traffic. -- **Limited-availability networking and secrets surfaces**: documented on their own feature pages when enabled on an instance. +- **Limited-availability networking surface**: documented on its own feature page when enabled on an instance. ## What you see after sign-in @@ -64,7 +64,7 @@ The **Fleet** view is the multi-node command center. The masthead summarizes onl The Fleet toolbar includes **Check Updates**, **Refresh**, and **Add node** for admins. The **Overview** tab supports search, sort, status filters, label filters, and a Grid or Topology view. Node cards show online state, resource use, container counts, version state, update actions, and direct drill-down into stacks on that node. -Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, a **Docker Labels** audit, blueprint **Deployments**, **Federation**, and fleet **Actions**. Federation placement (cordon and pin) is available on every tier. Routing and Secrets are limited-availability tabs when enabled on an instance. See [Licensing](/features/licensing) for the full tier breakdown. +Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, a **Docker Labels** audit, blueprint **Deployments**, **Secrets**, **Federation**, and fleet **Actions**. Federation placement (cordon and pin) is available on every tier. Routing is a limited-availability tab when enabled on an instance. See [Licensing](/features/licensing) for the full tier breakdown. ## Resources, templates, and logs diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx index eaf4711d..a064df88 100644 --- a/docs/reference/security.mdx +++ b/docs/reference/security.mdx @@ -61,7 +61,7 @@ Every self-hosted instance includes the full security stack. Some advanced gover - Limited-availability encrypted, versioned env-var bundles pushed to labeled nodes' stacks when the surface is present. Sealed with the same data key as MFA and registry credentials. + Encrypted, versioned env-var bundles pushed to labeled nodes' stacks. Sealed with the same data key as MFA and registry credentials. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 7585e430..7aededc2 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -72,7 +72,6 @@ export function FleetView({ const containerLabelsEnabled = hasCapability('container-label-inventory'); // Visual fail-closed while /meta loads; paid/admin gates still apply when on. const canDiscoverRouting = experimentalReady && experimental && isPaid; - const canDiscoverSecrets = experimentalReady && experimental && isPaid && isAdmin; const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); @@ -104,9 +103,9 @@ export function FleetView({ if (controlledTab === undefined) setInternalTab(tab); }; - // Fall back only after experimental readiness settles. When experimental is - // on, also wait for license (and admin for secrets) so a paid deep link is - // not rewritten to Overview while isPaid is still the cold-load false. + // Fall back Routing deep links when experimental/license gates resolve false. + // Wait for license during cold load so a paid deep link is not rewritten to + // Overview while isPaid is still the cold-load false. useEffect(() => { if (!experimentalReady) return; if (activeTab === 'routing') { @@ -116,19 +115,10 @@ export function FleetView({ } if (licenseStatus !== 'ready') return; if (!isPaid) setActiveTab('overview'); - return; - } - if (activeTab === 'secrets') { - if (!experimental) { - setActiveTab('overview'); - return; - } - if (licenseStatus !== 'ready') return; - if (!isPaid || !isAdmin) setActiveTab('overview'); } // setActiveTab closes over onFleetActiveTabChange; listing deps explicitly. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [experimentalReady, experimental, licenseStatus, isPaid, isAdmin, activeTab]); + }, [experimentalReady, experimental, licenseStatus, isPaid, activeTab]); useEffect(() => { if (fleetUpdatesIntent) { @@ -217,7 +207,7 @@ export function FleetView({ Actions - {canDiscoverSecrets && ( + {isAdmin && ( Secrets @@ -347,7 +337,7 @@ export function FleetView({ unfiltered node list rather than the overview-filtered view. */} - {canDiscoverSecrets && ( + {isAdmin && ( diff --git a/frontend/src/components/__tests__/FleetView.experimental.test.tsx b/frontend/src/components/__tests__/FleetView.experimental.test.tsx index bbf9fc43..c68c8201 100644 --- a/frontend/src/components/__tests__/FleetView.experimental.test.tsx +++ b/frontend/src/components/__tests__/FleetView.experimental.test.tsx @@ -8,10 +8,11 @@ vi.mock('@/hooks/useExperimental', () => ({ })); vi.mock('@/context/LicenseContext', () => ({ - useLicense: () => ({ isPaid: true }), + useLicense: () => ({ isPaid: true, licenseStatus: 'ready' as const }), })); +const useAuthMock = vi.fn(() => ({ isAdmin: true as boolean, can: () => true as boolean })); vi.mock('@/context/AuthContext', () => ({ - useAuth: () => ({ isAdmin: true, can: () => true }), + useAuth: () => useAuthMock(), })); vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ hasCapability: () => false }), @@ -100,22 +101,23 @@ vi.mock('../fleet/DependencyMapTab', () => ({ DependencyMapTab: () => null })); vi.mock('../fleet/ContainerLabelsTab', () => ({ ContainerLabelsTab: () => null })); vi.mock('../PaidGate', () => ({ PaidGate: ({ children }: { children: React.ReactNode }) => <>{children} })); -describe('FleetView experimental discovery', () => { +describe('FleetView tab discovery and deep-link fallback', () => { beforeEach(() => { useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); + useAuthMock.mockReturnValue({ isAdmin: true, can: () => true }); }); - it('shows Routing and Secrets when experimental discovery is on for paid admin', () => { + it('shows Routing when experimental is on; Secrets always visible for Admin', () => { render(); expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy(); expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy(); }); - it('hides Routing and Secrets when experimental discovery is off', () => { + it('hides Routing when experimental off; Secrets stays visible for Admin', () => { useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); render(); expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull(); - expect(screen.queryByRole('tab', { name: /secrets/i })).toBeNull(); + expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy(); expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy(); expect(screen.getByRole('tab', { name: /federation/i })).toBeTruthy(); expect(screen.getByRole('tab', { name: /actions/i })).toBeTruthy(); @@ -173,4 +175,29 @@ describe('FleetView experimental discovery', () => { await waitFor(() => expect(onTab).toHaveBeenCalledWith('overview')); expect(onTab).toHaveBeenCalledTimes(1); }); + + it('does not rewrite a Secrets deep link for Admin when experimental is off', () => { + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); + const onTab = vi.fn(); + render( + , + ); + // No fallback rewrite; Secrets is always available to Admin + expect(onTab).not.toHaveBeenCalled(); + }); + + it('hides Secrets tab for non-admin', () => { + useAuthMock.mockReturnValue({ isAdmin: false, can: () => false }); + render(); + expect(screen.queryByRole('tab', { name: /secrets/i })).toBeNull(); + // Routing still visible (gated on experimental + paid, not admin) + expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy(); + // Unrelated tabs still visible + expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy(); + }); }); diff --git a/frontend/src/lib/routing/reachability.test.ts b/frontend/src/lib/routing/reachability.test.ts index 20f1ed5e..951ea1e4 100644 --- a/frontend/src/lib/routing/reachability.test.ts +++ b/frontend/src/lib/routing/reachability.test.ts @@ -90,19 +90,29 @@ describe('reachability', () => { expect(isViewHidden('audit-log', ctx({ isPaid: true, can: () => false }))).toBe(true); }); - it('hides routing and secrets fleet tabs only after experimentalReady when off', () => { + it('hides routing fleet tab only after experimentalReady when off; secrets always visible for admin', () => { const loading = ctx({ experimental: false, experimentalReady: false }); expect(isFleetTabHidden('routing', loading)).toBe(false); expect(isFleetTabHidden('secrets', loading)).toBe(false); const off = ctx({ experimental: false, experimentalReady: true }); expect(isFleetTabHidden('routing', off)).toBe(true); - expect(isFleetTabHidden('secrets', off)).toBe(true); + expect(isFleetTabHidden('secrets', off)).toBe(false); expect(isFleetTabHidden('deployments', off)).toBe(false); expect(isFleetTabHidden('federation', off)).toBe(false); expect(isFleetTabHidden('actions', off)).toBe(false); }); + it('hides secrets fleet tab for non-admin after authz ready', () => { + // Cold load: permissions not ready, don't hide yet (deep link survives) + const loading = ctx({ isAdmin: false, permissionsStatus: 'loading' }); + expect(isFleetTabHidden('secrets', loading)).toBe(false); + + // Permissions settled: non-admin deep link normalizes to overview + const ready = ctx({ isAdmin: false, permissionsStatus: 'ready' }); + expect(isFleetTabHidden('secrets', ready)).toBe(true); + }); + it('does not hide fleet-mesh settings for experimental off', () => { const off = ctx({ experimental: false, experimentalReady: true, scheduledOpsAccessible: false, isAdmin: true }); diff --git a/frontend/src/lib/routing/reachability.ts b/frontend/src/lib/routing/reachability.ts index adbce787..4b7c7b7c 100644 --- a/frontend/src/lib/routing/reachability.ts +++ b/frontend/src/lib/routing/reachability.ts @@ -65,8 +65,9 @@ export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContex export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean { if (!authzReady(ctx)) return false; if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true; + if (tab === 'secrets' && !ctx.isAdmin) return true; // Defer experimental hide until ready so deep links survive cold load. - if ((tab === 'routing' || tab === 'secrets') && experimentalDiscoveryReady(ctx) && !ctx.experimental) { + if (tab === 'routing' && experimentalDiscoveryReady(ctx) && !ctx.experimental) { return true; } return false;