feat(fleet-secrets): graduate encrypted fleet-wide environment bundles to Community (#1758)

* feat(fleet-secrets): graduate encrypted fleet-wide environment bundles to Community

* fix(fleet-secrets): update reachability test for Secrets community graduation

* fix(fleet-secrets): address review findings

* fix(fleet-secrets): add HTTP-level Community admin push/import tests and non-admin tab-hidden test
This commit is contained in:
Anso
2026-08-03 08:42:46 -04:00
committed by GitHub
parent 5240957ff3
commit 5f5cd5ea51
11 changed files with 183 additions and 73 deletions
+120 -29
View File
@@ -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 ----
+1 -10
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
+3 -3
View File
@@ -12,7 +12,7 @@ The unit of work is the **bundle**. One bundle has one current `kv` payload; pus
</Frame>
<Note>
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.
</Note>
## 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.
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -61,7 +61,7 @@ Every self-hosted instance includes the full security stack. Some advanced gover
</Card>
<Card title="Fleet Secrets" icon="key-skeleton" href="/features/fleet-secrets">
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.
</Card>
<Card title="Webhook signatures" icon="signature" href="/features/webhooks">
+6 -16
View File
@@ -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({
<Wrench className="w-4 h-4 mr-1.5" />Actions
</TabsTrigger>
</TabsHighlightItem>
{canDiscoverSecrets && (
{isAdmin && (
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets">
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
@@ -347,7 +337,7 @@ export function FleetView({
unfiltered node list rather than the overview-filtered view. */}
<FleetActionsTab nodes={overview.nodes} />
</TabsContent>
{canDiscoverSecrets && (
{isAdmin && (
<TabsContent value="secrets">
<SecretsTab />
</TabsContent>
@@ -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(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
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(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
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(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="secrets"
onFleetActiveTabChange={onTab}
/>,
);
// 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(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
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();
});
});
+12 -2
View File
@@ -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 });
+2 -1
View File
@@ -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;