mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
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:
@@ -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,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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user