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:
parent
3a36c14162
commit
8a4514d20a
2 changed files with 227 additions and 18 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue