mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
fix(mesh): hide node and stack management controls from non-admins (#1284)
* fix(mesh): hide node and stack management controls from non-admins The Routing tab rendered the per-node mesh enable/disable toggle and the stack opt-in/opt-out controls for any Admiral-tier user, but those backend routes require the admin role. A non-admin viewer on an Admiral instance saw controls that returned 403. Thread a canManage flag (true only for admins) from the Fleet view into the Routing tab, its node cards, and the opt-in sheet so non-admins get a read-only Routing tab: the enable/disable toggle, add-stack, and opt-in/opt-out controls are hidden, while status, aliases, topology, activity, diagnostics, and the alias test probe stay available. This mirrors the Federation tab's existing read-only treatment for non-admins. Add backend route-gating tests covering the tier and admin-role guards on every mesh route, and frontend render-gate tests for the node card and the opt-in sheet in both density layouts. * refactor(mesh): require canManage on the routing-node-card primitive Remove the permissive `canManage = true` default on the shared routing-node-card primitive so a new call site cannot render the management controls without an explicit decision. Every current caller already passes the flag; the type now enforces it. Drop the omitted-prop test, which covered a state the compiler now prevents.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Gate coverage for the mesh router.
|
||||
*
|
||||
* Every /api/mesh route is tier-gated (requireAdmiral). The five operator
|
||||
* mutations are additionally role-gated (requireAdmin): node enable/disable,
|
||||
* stack opt-in/opt-out, and the override regen. The operator read routes
|
||||
* (status, aliases, activity, diagnostics) stay reachable for any Admiral-tier
|
||||
* user regardless of role, which is what lets a non-admin see a read-only
|
||||
* Routing tab. The node-to-node routes that central calls over the proxy on the
|
||||
* operator's behalf (local-override PUT/DELETE, alias test) are Admiral-gated
|
||||
* but intentionally not admin-gated. These tests lock that split so the backend
|
||||
* can never silently diverge from the matching frontend render gate (a button
|
||||
* that 403s, or a feature an owner cannot see).
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let defaultNodeId: number;
|
||||
|
||||
function userToken(username: string): string {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(username);
|
||||
if (!user) throw new Error(`missing test user ${username}`);
|
||||
return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
}
|
||||
|
||||
function setTier(tier: 'community' | 'paid', variant: 'skipper' | 'admiral' | null): void {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
const viewerHash = await bcrypt.hash('password123', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'mesh-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1;
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Default every test to a fully entitled Admiral instance; tier-rejection
|
||||
// tests override this locally.
|
||||
setTier('paid', 'admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('mesh tier gate (requireAdmiral)', () => {
|
||||
it('rejects Community tier with PAID_REQUIRED', async () => {
|
||||
setTier('community', null);
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/aliases')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects a paid non-Admiral variant with ADMIRAL_REQUIRED', async () => {
|
||||
setTier('paid', 'skipper');
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/aliases')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects Community tier on a mutation before the role gate runs', async () => {
|
||||
setTier('community', null);
|
||||
const res = await request(app)
|
||||
.post('/api/mesh/regen-overrides')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mesh read routes are visible to a non-admin Admiral user', () => {
|
||||
it('returns aliases to a viewer', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/aliases')
|
||||
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.aliases)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns activity to a viewer', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/activity')
|
||||
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.events)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns status to a viewer', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/status')
|
||||
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.nodes)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mesh mutation routes require the admin role (requireAdmin)', () => {
|
||||
const mutationRoutes: { name: string; path: () => string }[] = [
|
||||
{ name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' },
|
||||
{ name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` },
|
||||
{ name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` },
|
||||
{ name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` },
|
||||
{ name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` },
|
||||
];
|
||||
|
||||
for (const route of mutationRoutes) {
|
||||
it(`${route.name} rejects a non-admin Admiral user with ADMIN_REQUIRED`, async () => {
|
||||
const res = await request(app)
|
||||
.post(route.path())
|
||||
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
}
|
||||
|
||||
it('lets an Admiral admin pass both gates on regen-overrides', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/mesh/regen-overrides')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('regenerated');
|
||||
});
|
||||
|
||||
it('lets an Admiral admin past both gates on a node mutation (not gate-rejected)', async () => {
|
||||
// Locks the guard order (tier before role) for a mutation other than
|
||||
// regen-overrides: an admin must never be rejected by either gate. The
|
||||
// handler may still 4xx/5xx for other reasons in the test environment;
|
||||
// only the gate codes are asserted absent.
|
||||
const res = await request(app)
|
||||
.post(`/api/mesh/nodes/${defaultNodeId}/enable`)
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(res.body.code).not.toBe('ADMIRAL_REQUIRED');
|
||||
expect(res.body.code).not.toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user