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);
});
});