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:
Anso
2026-06-02 16:09:25 -04:00
committed by GitHub
parent 02f98ab90a
commit c82a39c65a
8 changed files with 417 additions and 34 deletions
@@ -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');
});
});