diff --git a/src/web/server.test.ts b/src/web/server.test.ts index d68400e..67a3388 100644 --- a/src/web/server.test.ts +++ b/src/web/server.test.ts @@ -1158,3 +1158,204 @@ describe('Web Server API Endpoints', () => { }); }); }); + +describe('Web Server Auth', () => { + let store: InMemoryEventStore; + let server: WebServer; + let port: number; + const AUTH_TOKEN = 'test-secret-token-12345'; + + const createEvent = (overrides: Partial = {}): LogEvent => ({ + ts: Date.now(), + worker: 'w-auth-test', + level: 'info', + msg: 'Auth test message', + ...overrides, + }); + + const validEvent = { + ts: new Date().toISOString(), + event: 'auth.test', + worker: 'auth-test-worker', + }; + + beforeEach(async () => { + store = new InMemoryEventStore(); + resetCrossReferenceManager(); + port = 31000 + Math.floor(Math.random() * 1000); + + server = createWebServer({ + port, + logPath: '/tmp/test-logs', + store, + authToken: AUTH_TOKEN, + }); + + await new Promise((resolve) => { + server.on('start', () => resolve()); + server.start(); + }); + }); + + afterEach(async () => { + await new Promise((resolve) => { + server.on('stop', () => resolve()); + server.stop(); + }); + store.clear(); + resetCrossReferenceManager(); + }); + + const fetchApi = async (path: string, options?: RequestInit) => { + return fetch(`http://localhost:${port}${path}`, options); + }; + + describe('POST /api/events auth', () => { + it('should reject POST without Authorization header with 401', async () => { + const response = await fetchApi('/api/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validEvent), + }); + + expect(response.status).toBe(401); + const data = await response.json() as any; + expect(data.error).toBe('Missing authorization'); + expect(data.message).toContain('Authorization header required'); + }); + + it('should reject POST with wrong token with 403', async () => { + const response = await fetchApi('/api/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer wrong-token', + }, + body: JSON.stringify(validEvent), + }); + + expect(response.status).toBe(403); + const data = await response.json() as any; + expect(data.error).toBe('Forbidden'); + expect(data.message).toContain('Invalid or expired token'); + }); + + it('should reject POST with malformed Authorization header with 403', async () => { + const response = await fetchApi('/api/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Basic abc123', + }, + body: JSON.stringify(validEvent), + }); + + expect(response.status).toBe(403); + }); + + it('should accept POST with correct Bearer token with 201', async () => { + const response = await fetchApi('/api/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify(validEvent), + }); + + expect(response.status).toBe(201); + const data = await response.json() as any; + expect(data.success).toBe(true); + expect(data.event).toBeDefined(); + }); + + it('should reject empty Bearer token', async () => { + const response = await fetchApi('/api/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ', + }, + body: JSON.stringify(validEvent), + }); + + expect(response.status).toBe(403); + }); + }); + + describe('POST /api/events/batch auth', () => { + const batchEvents = [ + { ts: new Date().toISOString(), event: 'batch.auth.1', worker: 'auth-worker' }, + { ts: new Date().toISOString(), event: 'batch.auth.2', worker: 'auth-worker' }, + ]; + + it('should reject batch POST without auth with 401', async () => { + const response = await fetchApi('/api/events/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(batchEvents), + }); + + expect(response.status).toBe(401); + }); + + it('should accept batch POST with correct token', async () => { + const response = await fetchApi('/api/events/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify(batchEvents), + }); + + expect(response.status).toBe(201); + const data = await response.json() as any; + expect(data.success).toBe(true); + expect(data.ingested).toBe(2); + }); + }); + + describe('POST /api/cost/alerts/:id/acknowledge auth', () => { + it('should reject cost alert acknowledge without auth with 401', async () => { + const response = await fetchApi('/api/cost/alerts/test-alert/acknowledge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + expect(response.status).toBe(401); + }); + }); + + describe('GET endpoints not affected by auth', () => { + it('should allow GET /api/health without auth', async () => { + const response = await fetchApi('/api/health'); + expect(response.status).toBe(200); + }); + + it('should allow GET /api/workers without auth', async () => { + const response = await fetchApi('/api/workers'); + expect(response.status).toBe(200); + }); + + it('should allow GET /api/events without auth', async () => { + const response = await fetchApi('/api/events'); + expect(response.status).toBe(200); + }); + + it('should allow GET /api/collisions without auth', async () => { + const response = await fetchApi('/api/collisions'); + expect(response.status).toBe(200); + }); + + it('should allow GET /api/xref/stats without auth', async () => { + const response = await fetchApi('/api/xref/stats'); + expect(response.status).toBe(200); + }); + + it('should allow GET /api/cost/summary without auth', async () => { + const response = await fetchApi('/api/cost/summary'); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/src/web/server.ts b/src/web/server.ts index 265ab9b..6a64287 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -64,24 +64,9 @@ export function createWebServer(options: WebServerOptions): WebServer { httpServer = createServer(app); wsServer = new WebSocketServer({ server: httpServer }); - // ── OTLP/HTTP routes (mounted before json middleware so raw body is available) ── - if (otlpHttpPort) { - const otlpRouter = createOtlpHttpRouter({ - onEvent: (event: LogEvent) => { - store.add(event); - broadcast(event); - }, - }); - app.use(otlpRouter); - } - - // Parse JSON bodies - app.use(express.json({ limit: MAX_PAYLOAD_SIZE.toString() })); - - // Create auth middleware for POST endpoints if token is configured + // ── Auth middleware for all POST routes ── const authMiddleware = (req: Request, res: Response, next: () => void) => { if (!authToken) { - // No auth configured, allow all requests next(); return; } @@ -101,6 +86,29 @@ export function createWebServer(options: WebServerOptions): WebServer { next(); }; + // Apply auth to all POST requests (event ingestion, OTLP, etc.) + app.use((req, res, next) => { + if (req.method === 'POST') { + authMiddleware(req, res, next); + } else { + next(); + } + }); + + // ── OTLP/HTTP routes (mounted before json middleware so raw body is available) ── + if (otlpHttpPort) { + const otlpRouter = createOtlpHttpRouter({ + onEvent: (event: LogEvent) => { + store.add(event); + broadcast(event); + }, + }); + app.use(otlpRouter); + } + + // Parse JSON bodies + app.use(express.json({ limit: MAX_PAYLOAD_SIZE.toString() })); + wsServer.on('connection', (ws: WebSocket) => { clients.add(ws); console.log(`WebSocket client connected (${clients.size} total)`); @@ -152,7 +160,7 @@ export function createWebServer(options: WebServerOptions): WebServer { }); // POST endpoint to ingest NEEDLE telemetry events - app.post('/api/events', authMiddleware, (req: Request, res: Response) => { + app.post('/api/events', (req: Request, res: Response) => { try { const eventObj = req.body; @@ -194,7 +202,7 @@ export function createWebServer(options: WebServerOptions): WebServer { }); // POST endpoint to ingest batched NEEDLE telemetry events - app.post('/api/events/batch', authMiddleware, (req: Request, res: Response) => { + app.post('/api/events/batch', (req: Request, res: Response) => { try { const eventsArray = req.body;