fix(fleet): add auth middleware, input validation, and design system compliance (#536)

Add authMiddleware to all 13 fleet endpoints that were previously
accessible without authentication. Add NaN validation for parseInt
params, stackName validation on snapshot restore, and description
length cap on snapshot creation. Clean up updateTracker entries on
node deletion to prevent memory leaks.

Replace hardcoded colors with design system tokens, swap Select for
Combobox, replace overflow-y-auto with ScrollArea, fix card styling
(shadow-card-bevel, border tokens). Fix stale container data by
always refetching on stack expand with a loading guard against
concurrent requests.

Add operational logging for state-changing fleet operations and
diagnostic logging gated behind Developer Mode. Add 20 fleet tests
covering auth enforcement, input validation, tier gating, and
snapshot CRUD lifecycle.
This commit is contained in:
Anso
2026-04-12 20:28:18 -04:00
committed by GitHub
parent bbaee7f7f0
commit 1702dabb7a
7 changed files with 333 additions and 62 deletions
+226
View File
@@ -0,0 +1,226 @@
/**
* Tests for fleet management API endpoints.
* Covers auth enforcement, input validation, overview, snapshot CRUD, and tier gating.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
function mockTier(tier: 'paid' | 'community') {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
}
// ─── Auth Enforcement ───
describe('Fleet endpoints require authentication', () => {
it('GET /api/fleet/overview returns 401 without auth', async () => {
const res = await request(app).get('/api/fleet/overview');
expect(res.status).toBe(401);
});
it('GET /api/fleet/update-status returns 401 without auth', async () => {
const res = await request(app).get('/api/fleet/update-status');
expect(res.status).toBe(401);
});
it('POST /api/fleet/snapshots returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet/snapshots').send({ description: 'test' });
expect(res.status).toBe(401);
});
it('GET /api/fleet/snapshots returns 401 without auth', async () => {
const res = await request(app).get('/api/fleet/snapshots');
expect(res.status).toBe(401);
});
it('DELETE /api/fleet/snapshots/1 returns 401 without auth', async () => {
const res = await request(app).delete('/api/fleet/snapshots/1');
expect(res.status).toBe(401);
});
it('POST /api/fleet/nodes/1/update returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet/nodes/1/update');
expect(res.status).toBe(401);
});
});
// ─── Input Validation ───
describe('Fleet input validation', () => {
afterEach(() => vi.restoreAllMocks());
it('rejects NaN nodeId on GET /api/fleet/node/:nodeId/stacks', async () => {
mockTier('paid');
const res = await request(app)
.get('/api/fleet/node/abc/stacks')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid node id/i);
});
it('rejects NaN nodeId on GET /api/fleet/node/:nodeId/stacks/:stackName/containers', async () => {
mockTier('paid');
const res = await request(app)
.get('/api/fleet/node/xyz/stacks/mystack/containers')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid node id/i);
});
it('rejects invalid stackName on containers endpoint', async () => {
mockTier('paid');
// Stack name with characters that fail the alphanumeric+dash+underscore regex
const res = await request(app)
.get('/api/fleet/node/1/stacks/bad%20stack%21/containers')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid stack name/i);
});
it('rejects NaN snapshot ID on GET /api/fleet/snapshots/:id', async () => {
mockTier('paid');
const res = await request(app)
.get('/api/fleet/snapshots/notanumber')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid snapshot id/i);
});
it('rejects oversized snapshot description', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/snapshots')
.set('Authorization', authHeader)
.send({ description: 'x'.repeat(501) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/500 characters/i);
});
});
// ─── Fleet Overview ───
describe('GET /api/fleet/overview', () => {
it('returns 200 with an array', async () => {
const res = await request(app)
.get('/api/fleet/overview')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('includes the local node', async () => {
const res = await request(app)
.get('/api/fleet/overview')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
const local = res.body.find((n: { type: string }) => n.type === 'local');
expect(local).toBeDefined();
expect(local.name).toBeTruthy();
});
});
// ─── Tier Gating ───
describe('Fleet tier gating', () => {
afterEach(() => vi.restoreAllMocks());
it('GET /api/fleet/update-status returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/update-status')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('GET /api/fleet/snapshots returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/snapshots')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
});
// ─── Snapshot CRUD ───
describe('Fleet snapshot lifecycle', () => {
afterEach(() => vi.restoreAllMocks());
let snapshotId: number;
it('creates a snapshot (POST /api/fleet/snapshots)', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/snapshots')
.set('Authorization', authHeader)
.send({ description: 'Test snapshot' });
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('id');
expect(res.body.description).toBe('Test snapshot');
snapshotId = res.body.id;
});
it('lists snapshots (GET /api/fleet/snapshots)', async () => {
mockTier('paid');
const res = await request(app)
.get('/api/fleet/snapshots')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('snapshots');
expect(res.body).toHaveProperty('total');
expect(res.body.total).toBeGreaterThanOrEqual(1);
});
it('gets snapshot detail (GET /api/fleet/snapshots/:id)', async () => {
mockTier('paid');
const res = await request(app)
.get(`/api/fleet/snapshots/${snapshotId}`)
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.id).toBe(snapshotId);
expect(res.body).toHaveProperty('nodes');
});
it('returns 404 for missing snapshot', async () => {
mockTier('paid');
const res = await request(app)
.get('/api/fleet/snapshots/99999')
.set('Authorization', authHeader);
expect(res.status).toBe(404);
});
it('deletes a snapshot (DELETE /api/fleet/snapshots/:id)', async () => {
mockTier('paid');
const res = await request(app)
.delete(`/api/fleet/snapshots/${snapshotId}`)
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.message).toMatch(/deleted/i);
// Verify it's gone
const check = await request(app)
.get(`/api/fleet/snapshots/${snapshotId}`)
.set('Authorization', authHeader);
expect(check.status).toBe(404);
});
});
+61 -21
View File
@@ -7,6 +7,7 @@ import helmet from 'helmet';
import WebSocket, { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
import DockerController, { globalDockerNetwork, type CreateNetworkOptions, type NetworkDriver } from './services/DockerController';
import type Dockerode from 'dockerode';
import { FileSystemService } from './services/FileSystemService';
import { ComposeService } from './services/ComposeService';
import bcrypt from 'bcrypt';
@@ -1388,10 +1389,12 @@ interface FleetNodeOverview {
stacks: string[] | null;
}
app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise<void> => {
app.get('/api/fleet/overview', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
try {
const debug = isDebugEnabled();
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
if (debug) console.debug('[Fleet:debug] Overview requested, fetching', nodes.length, 'nodes');
const results = await Promise.allSettled(
nodes.map(async (node): Promise<FleetNodeOverview> => {
@@ -1416,6 +1419,10 @@ app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise<voi
};
});
if (debug) {
const online = overview.filter(n => n.status === 'online').length;
console.debug('[Fleet:debug] Overview complete:', online, 'online,', overview.length - online, 'offline');
}
res.json(overview);
} catch (error) {
console.error('[Fleet] Overview error:', error);
@@ -1424,11 +1431,12 @@ app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise<voi
});
// Paid-gated: detailed stack info per node
app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): Promise<void> => {
app.get('/api/fleet/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
@@ -1449,11 +1457,13 @@ app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): P
return;
}
const stacks = await response.json();
if (isDebugEnabled()) console.debug('[Fleet:debug] Node stacks:', nodeId, node.type, Array.isArray(stacks) ? stacks.length : 0, 'stacks');
res.json(stacks);
return;
}
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
if (isDebugEnabled()) console.debug('[Fleet:debug] Node stacks:', nodeId, node.type, stacks.length, 'stacks');
res.json(stacks);
} catch (error) {
console.error('[Fleet] Node stacks error:', error);
@@ -1462,11 +1472,12 @@ app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): P
});
// Paid-gated: container details for a specific stack on a specific node
app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Request, res: Response): Promise<void> => {
app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
@@ -1498,6 +1509,7 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ
const dockerController = DockerController.getInstance(nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (isDebugEnabled()) console.debug('[Fleet:debug] Stack containers:', nodeId, stackName, containers.length, 'containers');
res.json(containers);
} catch (error) {
console.error('[Fleet] Node stack containers error:', error);
@@ -1506,7 +1518,7 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ
});
// Fleet Update Status — returns version comparison and active update status for all nodes
app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promise<void> => {
app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
if (!requirePaid(_req, res)) return;
try {
const db = DatabaseService.getInstance();
@@ -1642,6 +1654,10 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis
};
});
if (isDebugEnabled()) {
const trackerStates = Array.from(updateTracker.entries()).map(([nid, t]) => `${nid}:${t.status}`);
console.debug('[Fleet:debug] Update status:', nodeStatuses.length, 'nodes, trackers:', trackerStates.join(', ') || 'none');
}
res.json({ nodes: nodeStatuses });
} catch (error) {
console.error('[Fleet] Update status error:', error);
@@ -1650,10 +1666,11 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis
});
// Trigger update on a specific node
app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): Promise<void> => {
app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node) {
@@ -1675,6 +1692,8 @@ app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response):
updateTracker.delete(nodeId);
}
console.log('[Fleet] Update triggered for node', node.name, node.type);
if (node.type === 'local') {
if (!SelfUpdateService.getInstance().isAvailable()) {
res.status(503).json({ error: 'Self-update unavailable on the local node.' });
@@ -1734,7 +1753,7 @@ app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response):
});
// Trigger update on all outdated nodes
app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise<void> => {
app.post('/api/fleet/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const db = DatabaseService.getInstance();
@@ -1742,6 +1761,8 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise<v
const gatewayVersion = getSenchoVersion();
const { compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
console.log('[Fleet] Update-all triggered,', nodes.length, 'nodes registered');
// Filter to eligible candidates, then trigger all in parallel
const candidates = nodes.filter(node => {
if (node.type === 'local') return false;
@@ -1793,10 +1814,11 @@ app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise<v
});
// Clear update tracker entry for a specific node (dismiss or before retry)
app.delete('/api/fleet/nodes/:nodeId/update-status', async (req: Request, res: Response): Promise<void> => {
app.delete('/api/fleet/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
@@ -1811,7 +1833,7 @@ app.delete('/api/fleet/nodes/:nodeId/update-status', async (req: Request, res: R
});
// Clear all terminal (timed-out, failed, completed) tracker entries at once
app.delete('/api/fleet/update-status', async (req: Request, res: Response): Promise<void> => {
app.delete('/api/fleet/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
// Pre-fetch fresh latest version so the next GET has up-to-date data
if (req.query.recheck === 'true') {
@@ -1836,18 +1858,19 @@ async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
si.fsSize(),
]);
const isManagedByComposeDir = (c: any): boolean => {
const isManagedByComposeDir = (c: Dockerode.ContainerInfo): boolean => {
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
if (!workingDir) return false;
const resolved = path.resolve(workingDir);
return resolved === composeDir || resolved.startsWith(composeDir + path.sep);
};
const active = allContainers.filter((c: any) => c.State === 'running').length;
const exited = allContainers.filter((c: any) => c.State === 'exited').length;
const total = allContainers.length;
const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length;
const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length;
const containers = allContainers as Dockerode.ContainerInfo[];
const active = containers.filter(c => c.State === 'running').length;
const exited = containers.filter(c => c.State === 'exited').length;
const total = containers.length;
const managed = containers.filter(c => c.State === 'running' && isManagedByComposeDir(c)).length;
const unmanaged = containers.filter(c => c.State === 'running' && !isManagedByComposeDir(c)).length;
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
@@ -2033,12 +2056,16 @@ async function captureRemoteNodeFiles(node: Node): Promise<SnapshotNodeData> {
}
// Create fleet snapshot
app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const { description = '' } = req.body;
if (typeof description === 'string' && description.length > 500) {
res.status(400).json({ error: 'Description must be 500 characters or less' });
return;
}
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const username = req.user?.username || 'admin';
@@ -2098,6 +2125,7 @@ app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<vo
db.insertSnapshotFiles(snapshotId, allFiles);
}
console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks');
const snapshot = db.getSnapshot(snapshotId);
res.status(201).json(snapshot);
} catch (error) {
@@ -2107,7 +2135,7 @@ app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<vo
});
// List fleet snapshots
app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
app.get('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
@@ -2116,6 +2144,7 @@ app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<voi
const db = DatabaseService.getInstance();
const snapshots = db.getSnapshots(limit, offset);
const total = db.getSnapshotCount();
if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshots list: limit=', limit, 'offset=', offset, 'total=', total);
res.json({ snapshots, total });
} catch (error) {
console.error('[Fleet Snapshot] List error:', error);
@@ -2124,11 +2153,12 @@ app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<voi
});
// Get snapshot detail
app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
app.get('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; }
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(id);
if (!snapshot) {
@@ -2160,6 +2190,7 @@ app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise
})),
}));
if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshot detail:', id, files.length, 'files');
res.json({ ...snapshot, nodes });
} catch (error) {
console.error('[Fleet Snapshot] Detail error:', error);
@@ -2168,18 +2199,23 @@ app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise
});
// Restore a stack from snapshot
app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response): Promise<void> => {
app.post('/api/fleet/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const snapshotId = parseInt(req.params.id as string, 10);
if (isNaN(snapshotId)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; }
const { nodeId, stackName, redeploy = false } = req.body;
if (!nodeId || !stackName) {
res.status(400).json({ error: 'nodeId and stackName are required' });
return;
}
if (!isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(snapshotId);
@@ -2265,6 +2301,7 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response)
}
}
console.log('[Fleet] Snapshot restore:', snapshotId, 'node=', nodeId, 'stack=', stackName);
res.json({ message: 'Stack restored successfully', redeployed: redeploy });
} catch (error) {
console.error('[Fleet Snapshot] Restore error:', error);
@@ -2273,12 +2310,13 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response)
});
// Delete snapshot
app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
app.delete('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; }
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(id);
if (!snapshot) {
@@ -2286,6 +2324,7 @@ app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Prom
return;
}
db.deleteSnapshot(id);
console.log('[Fleet] Snapshot deleted:', id);
res.json({ message: 'Snapshot deleted' });
} catch (error) {
console.error('[Fleet Snapshot] Delete error:', error);
@@ -6145,10 +6184,11 @@ app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
DatabaseService.getInstance().deleteNode(id);
NodeRegistry.getInstance().evictConnection(id);
CacheService.getInstance().invalidate(`${REMOTE_META_NAMESPACE}:${id}`);
updateTracker.delete(id);
res.json({ success: true });
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to delete node:', error);
res.status(500).json({ error: error.message || 'Failed to delete node' });
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to delete node' });
}
});