feat(bd-n8y): apply auth middleware globally to all POST routes with tests

Move auth middleware before OTLP router mount and apply it as app-level
middleware for all POST requests. This protects event ingestion endpoints
(/api/events, /api/events/batch), OTLP endpoints (/v1/logs, /v1/traces,
/v1/metrics), and cost alert acknowledgement. GET endpoints remain open.
Adds comprehensive auth tests covering 401/403/201 responses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-23 15:59:29 -04:00
parent 3a36c14162
commit 8a4514d20a
2 changed files with 227 additions and 18 deletions

View file

@ -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> = {}): 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<void>((resolve) => {
server.on('start', () => resolve());
server.start();
});
});
afterEach(async () => {
await new Promise<void>((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);
});
});
});

View file

@ -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;