diff --git a/backend/src/__tests__/alerts-api.test.ts b/backend/src/__tests__/alerts-api.test.ts index 7f559ec8..566327fc 100644 --- a/backend/src/__tests__/alerts-api.test.ts +++ b/backend/src/__tests__/alerts-api.test.ts @@ -244,7 +244,7 @@ describe('POST /api/notifications/test', () => { .set('Cookie', authCookie) .send({ type: 'telegram', url: 'https://example.com' }); expect(res.status).toBe(400); - expect(res.body.error).toContain('discord, slack, or webhook'); + expect(res.body.error).toContain('discord, slack, webhook'); }); it('rejects missing type with 400', async () => { diff --git a/backend/src/__tests__/notification-routes-api.test.ts b/backend/src/__tests__/notification-routes-api.test.ts new file mode 100644 index 00000000..ebbe194b --- /dev/null +++ b/backend/src/__tests__/notification-routes-api.test.ts @@ -0,0 +1,435 @@ +/** + * Integration tests for Notification Routes CRUD endpoints, + * auth enforcement, input validation, and test dispatch. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let authCookie: string; +let viewerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + // Mock LicenseService so Admiral-gated routes are accessible + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + + // Create a viewer user for non-admin tests + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app) + .post('/api/auth/login') + .send({ username: 'viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +// --- Auth Enforcement --- + +describe('Notification Routes - auth enforcement', () => { + it('GET /api/notification-routes returns 401 without auth', async () => { + const res = await request(app).get('/api/notification-routes'); + expect(res.status).toBe(401); + }); + + it('POST /api/notification-routes returns 401 without auth', async () => { + const res = await request(app) + .post('/api/notification-routes') + .send({ name: 'test', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(401); + }); + + it('PUT /api/notification-routes/1 returns 401 without auth', async () => { + const res = await request(app) + .put('/api/notification-routes/1') + .send({ name: 'updated' }); + expect(res.status).toBe(401); + }); + + it('DELETE /api/notification-routes/1 returns 401 without auth', async () => { + const res = await request(app).delete('/api/notification-routes/1'); + expect(res.status).toBe(401); + }); + + it('POST /api/notification-routes/1/test returns 401 without auth', async () => { + const res = await request(app).post('/api/notification-routes/1/test'); + expect(res.status).toBe(401); + }); + + it('GET /api/notification-routes returns 403 for viewer', async () => { + const res = await request(app) + .get('/api/notification-routes') + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + }); + + it('POST /api/notification-routes returns 403 for viewer', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', viewerCookie) + .send({ name: 'test', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(403); + }); +}); + +// --- Agents Auth (now requires authMiddleware) --- + +describe('Agents endpoints - auth enforcement', () => { + it('GET /api/agents returns 401 without auth', async () => { + const res = await request(app).get('/api/agents'); + expect(res.status).toBe(401); + }); + + it('POST /api/agents returns 401 without auth', async () => { + const res = await request(app) + .post('/api/agents') + .send({ type: 'discord', url: 'https://discord.com/api/webhooks/123/abc', enabled: true }); + expect(res.status).toBe(401); + }); + + it('POST /api/agents returns 403 for viewer', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', viewerCookie) + .send({ type: 'discord', url: 'https://discord.com/api/webhooks/123/abc', enabled: true }); + expect(res.status).toBe(403); + }); + + it('GET /api/agents returns 200 with auth', async () => { + const res = await request(app) + .get('/api/agents') + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); +}); + +// --- Agents Validation --- + +describe('POST /api/agents - validation', () => { + it('rejects invalid type', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', authCookie) + .send({ type: 'telegram', url: 'https://example.com/hook', enabled: true }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('discord, slack, webhook'); + }); + + it('rejects non-HTTPS url', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', authCookie) + .send({ type: 'discord', url: 'http://example.com', enabled: true }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('HTTPS'); + }); + + it('rejects malformed url', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', authCookie) + .send({ type: 'discord', url: 'https://', enabled: true }); + expect(res.status).toBe(400); + }); + + it('rejects non-boolean enabled', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', authCookie) + .send({ type: 'discord', url: 'https://discord.com/api/webhooks/123/abc', enabled: 'yes' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('boolean'); + }); + + it('accepts valid agent and returns success', async () => { + const res = await request(app) + .post('/api/agents') + .set('Cookie', authCookie) + .send({ type: 'webhook', url: 'https://example.com/hook', enabled: true }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); + +// --- CRUD Operations --- + +describe('Notification Routes - CRUD', () => { + it('GET returns empty array initially', async () => { + const res = await request(app) + .get('/api/notification-routes') + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + let createdId: number; + + it('POST creates a route and returns 201', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Prod Discord', + stack_patterns: ['prod-api', 'prod-web'], + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/123/abc', + priority: 5, + enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + expect(res.body.name).toBe('Prod Discord'); + expect(res.body.stack_patterns).toEqual(['prod-api', 'prod-web']); + expect(res.body.channel_type).toBe('discord'); + expect(res.body.priority).toBe(5); + expect(res.body.enabled).toBe(true); + createdId = res.body.id; + }); + + it('GET returns created routes sorted by priority', async () => { + // Create a second route with lower priority (higher importance) + await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'Staging Slack', + stack_patterns: ['staging-api'], + channel_type: 'slack', + channel_url: 'https://hooks.slack.com/services/T00/B00/xyz', + priority: 0, + }); + + const res = await request(app) + .get('/api/notification-routes') + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.length).toBeGreaterThanOrEqual(2); + // First route should have lower priority number + expect(res.body[0].priority).toBeLessThanOrEqual(res.body[1].priority); + }); + + it('PUT updates specific fields', async () => { + const res = await request(app) + .put(`/api/notification-routes/${createdId}`) + .set('Cookie', authCookie) + .send({ name: 'Prod Discord Updated', priority: 10 }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Prod Discord Updated'); + expect(res.body.priority).toBe(10); + // Unchanged fields preserved + expect(res.body.channel_type).toBe('discord'); + }); + + it('PUT returns 404 for non-existent route', async () => { + const res = await request(app) + .put('/api/notification-routes/99999') + .set('Cookie', authCookie) + .send({ name: 'Ghost' }); + expect(res.status).toBe(404); + }); + + it('DELETE removes the route', async () => { + const res = await request(app) + .delete(`/api/notification-routes/${createdId}`) + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('DELETE returns 404 for non-existent route', async () => { + const res = await request(app) + .delete('/api/notification-routes/99999') + .set('Cookie', authCookie); + expect(res.status).toBe(404); + }); +}); + +// --- Validation --- + +describe('POST /api/notification-routes - validation', () => { + it('rejects empty name', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: '', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('Name'); + }); + + it('rejects name exceeding 100 characters', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'a'.repeat(101), stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('100'); + }); + + it('rejects empty stack_patterns array', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'test', stack_patterns: [], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(400); + }); + + it('rejects whitespace-only stack patterns', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'test', stack_patterns: [' ', ''], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(400); + }); + + it('rejects invalid channel_type', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'test', stack_patterns: ['app'], channel_type: 'telegram', channel_url: 'https://example.com' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('discord, slack, webhook'); + }); + + it('rejects non-HTTPS channel_url', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'test', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'http://example.com' }); + expect(res.status).toBe(400); + }); + + it('rejects malformed channel_url (no host)', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'test', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://' }); + expect(res.status).toBe(400); + }); + + it('rejects non-numeric priority', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'test', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc', priority: 'high' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('priority'); + }); + + it('deduplicates stack patterns', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'dedup test', stack_patterns: ['app', 'app', 'web'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(201); + expect(res.body.stack_patterns).toEqual(['app', 'web']); + + // Clean up + await request(app) + .delete(`/api/notification-routes/${res.body.id}`) + .set('Cookie', authCookie); + }); +}); + +// --- PUT Validation --- + +describe('PUT /api/notification-routes/:id - validation', () => { + let routeId: number; + + beforeAll(async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'PUT test', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + routeId = res.body.id; + }); + + it('rejects invalid route ID (NaN)', async () => { + const res = await request(app) + .put('/api/notification-routes/abc') + .set('Cookie', authCookie) + .send({ name: 'updated' }); + expect(res.status).toBe(400); + }); + + it('rejects non-boolean enabled', async () => { + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ enabled: 'yes' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('boolean'); + }); + + it('rejects non-numeric priority', async () => { + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ priority: 'high' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('priority'); + }); + + it('rejects malformed channel_url', async () => { + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ channel_url: 'https://' }); + expect(res.status).toBe(400); + }); + + it('rejects name exceeding 100 characters', async () => { + const res = await request(app) + .put(`/api/notification-routes/${routeId}`) + .set('Cookie', authCookie) + .send({ name: 'x'.repeat(101) }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('100'); + }); +}); + +// --- Test Dispatch --- + +describe('POST /api/notification-routes/:id/test', () => { + it('returns 404 for non-existent route', async () => { + const res = await request(app) + .post('/api/notification-routes/99999/test') + .set('Cookie', authCookie); + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid route ID', async () => { + const res = await request(app) + .post('/api/notification-routes/abc/test') + .set('Cookie', authCookie); + expect(res.status).toBe(400); + }); +}); + +// --- DELETE /api/notifications/:id NaN guard --- + +describe('DELETE /api/notifications/:id - validation', () => { + it('rejects NaN notification ID with 400', async () => { + const res = await request(app) + .delete('/api/notifications/abc') + .set('Cookie', authCookie); + expect(res.status).toBe(400); + expect(res.body.error).toContain('Invalid'); + }); +}); diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index 2e93fe22..2df01a98 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -10,6 +10,7 @@ const { mockGetEnabledNotificationRoutes, mockGetEnabledAgents, mockAddNotificationHistory, + mockUpdateNotificationDispatchError, } = vi.hoisted(() => ({ mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), mockGetEnabledAgents: vi.fn().mockReturnValue([]), @@ -20,6 +21,7 @@ const { timestamp: Date.now(), is_read: 0, }), + mockUpdateNotificationDispatchError: vi.fn(), })); vi.mock('../services/DatabaseService', () => ({ @@ -28,6 +30,7 @@ vi.mock('../services/DatabaseService', () => ({ getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, getEnabledAgents: mockGetEnabledAgents, addNotificationHistory: mockAddNotificationHistory, + updateNotificationDispatchError: mockUpdateNotificationDispatchError, }), }, })); @@ -245,4 +248,38 @@ describe('NotificationService - routing logic', () => { }) ); }); + + it('records dispatch errors when route webhook fails', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]); + mockFetch.mockRejectedValueOnce(new Error('Connection refused')); + + await svc.dispatchAlert('error', 'Test', 'my-app'); + + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 1, // notification id from mock + expect.stringContaining('Connection refused') + ); + }); + + it('records dispatch errors when global agent webhook fails', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + mockFetch.mockRejectedValueOnce(new Error('Timeout')); + + await svc.dispatchAlert('warning', 'Host alert'); + + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 1, + expect.stringContaining('Timeout') + ); + }); + + it('does not record dispatch errors when all dispatches succeed', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]); + mockFetch.mockResolvedValueOnce({ ok: true }); + + await svc.dispatchAlert('info', 'All good', 'my-app'); + + expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index baebf831..e6309c2e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4203,22 +4203,49 @@ app.get('/api/system/cache-stats', async (req: Request, res: Response) => { // --- Notification & Alerting Routes --- -app.get('/api/agents', async (req: Request, res: Response) => { +const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const; + +/** Trim, deduplicate, and drop empty entries from a stack_patterns array. */ +const cleanStackPatterns = (patterns: string[]): string[] => + [...new Set(patterns.map(p => p.trim()).filter(Boolean))]; + +/** Validate that a string is a well-formed HTTPS URL. Returns an error string or null. */ +function validateHttpsUrl(value: unknown): string | null { + if (!value || typeof value !== 'string' || !value.startsWith('https://')) return 'must be a valid HTTPS URL'; + try { new URL(value); } catch { return 'is not a valid URL'; } + return null; +} + +app.get('/api/agents', authMiddleware, async (req: Request, res: Response) => { try { const agents = DatabaseService.getInstance().getAgents(); res.json(agents); } catch (error) { + console.error('Failed to fetch agents:', error); res.status(500).json({ error: 'Failed to fetch agents' }); } }); -app.post('/api/agents', async (req: Request, res: Response) => { +app.post('/api/agents', authMiddleware, async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { - const agent = req.body; - DatabaseService.getInstance().upsertAgent(agent); + const { type, url, enabled } = req.body; + if (!type || !NOTIFICATION_CHANNEL_TYPES.includes(type)) { + res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); + return; + } + const urlErr = validateHttpsUrl(url); + if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; } + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + DatabaseService.getInstance().upsertAgent({ type, url, enabled }); + console.log(`[Agents] Agent ${type} updated`); + if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`); res.json({ success: true }); } catch (error) { + console.error('Failed to update agent:', error); res.status(500).json({ error: 'Failed to update agent' }); } }); @@ -4382,6 +4409,7 @@ app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Re app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; } DatabaseService.getInstance().deleteNotification(id); res.json({ success: true }); } catch (error) { @@ -4398,26 +4426,20 @@ app.delete('/api/notifications', authMiddleware, async (req: Request, res: Respo } }); -const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const; - app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { const { type, url } = req.body; if (!type || !NOTIFICATION_CHANNEL_TYPES.includes(type)) { - res.status(400).json({ error: 'type must be discord, slack, or webhook' }); + res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); return; } - if (!url || typeof url !== 'string' || !url.startsWith('https://')) { - res.status(400).json({ error: 'url must be a valid HTTPS URL' }); - return; - } - try { new URL(url); } catch { res.status(400).json({ error: 'url is not a valid URL' }); return; } + const urlErr = validateHttpsUrl(url); + if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; } await NotificationService.getInstance().testDispatch(type, url); res.json({ success: true }); } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - res.status(500).json({ error: 'Test failed', details: msg }); + res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) }); } }); @@ -4445,23 +4467,34 @@ app.post('/api/notification-routes', authMiddleware, async (req: Request, res: R res.status(400).json({ error: 'Name is required' }); return; } - if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string' || !(p as string).trim())) { + if (name.trim().length > 100) { + res.status(400).json({ error: 'Name must be 100 characters or fewer' }); + return; + } + if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) { res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' }); return; } - if (!NOTIFICATION_CHANNEL_TYPES.includes(channel_type)) { - res.status(400).json({ error: 'channel_type must be discord, slack, or webhook' }); + const cleanedPatterns = cleanStackPatterns(stack_patterns); + if (cleanedPatterns.length === 0) { + res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' }); return; } - if (!channel_url || typeof channel_url !== 'string' || !channel_url.startsWith('https://')) { - res.status(400).json({ error: 'channel_url must be a valid HTTPS URL' }); + if (!NOTIFICATION_CHANNEL_TYPES.includes(channel_type)) { + res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); + return; + } + const channelUrlErr = validateHttpsUrl(channel_url); + if (channelUrlErr) { res.status(400).json({ error: `channel_url ${channelUrlErr}` }); return; } + if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) { + res.status(400).json({ error: 'priority must be a finite number' }); return; } const now = Date.now(); const route = DatabaseService.getInstance().createNotificationRoute({ name: name.trim(), - stack_patterns: stack_patterns.map((p: string) => p.trim()), + stack_patterns: cleanedPatterns, channel_type, channel_url: channel_url.trim(), priority: typeof priority === 'number' ? priority : 0, @@ -4469,6 +4502,8 @@ app.post('/api/notification-routes', authMiddleware, async (req: Request, res: R created_at: now, updated_at: now, }); + console.log(`[Routes] Route "${route.name}" created (id=${route.id})`); + if (isDebugEnabled()) console.log(`[Routes:diag] Route "${route.name}" created with patterns=[${cleanedPatterns}], channel=${channel_type}`); res.status(201).json(route); } catch (error) { console.error('Failed to create notification route:', error); @@ -4492,22 +4527,42 @@ app.put('/api/notification-routes/:id', authMiddleware, async (req: Request, res res.status(400).json({ error: 'Name must be a non-empty string' }); return; } - if (stack_patterns !== undefined && (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string' || !(p as string).trim()))) { - res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' }); + if (name !== undefined && name.trim().length > 100) { + res.status(400).json({ error: 'Name must be 100 characters or fewer' }); return; } + let cleanedPatterns: string[] | undefined; + if (stack_patterns !== undefined) { + if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) { + res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' }); + return; + } + cleanedPatterns = cleanStackPatterns(stack_patterns); + if (cleanedPatterns.length === 0) { + res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' }); + return; + } + } if (channel_type !== undefined && !NOTIFICATION_CHANNEL_TYPES.includes(channel_type)) { - res.status(400).json({ error: 'channel_type must be discord, slack, or webhook' }); + res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); return; } - if (channel_url !== undefined && (typeof channel_url !== 'string' || !channel_url.startsWith('https://'))) { - res.status(400).json({ error: 'channel_url must be a valid HTTPS URL' }); + if (channel_url !== undefined) { + const urlErr = validateHttpsUrl(channel_url); + if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; } + } + if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) { + res.status(400).json({ error: 'priority must be a finite number' }); + return; + } + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); return; } const updates: Record = { updated_at: Date.now() }; if (name !== undefined) updates.name = name.trim(); - if (stack_patterns !== undefined) updates.stack_patterns = stack_patterns.map((p: string) => p.trim()); + if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns; if (channel_type !== undefined) updates.channel_type = channel_type; if (channel_url !== undefined) updates.channel_url = channel_url.trim(); if (priority !== undefined) updates.priority = priority; @@ -4515,6 +4570,8 @@ app.put('/api/notification-routes/:id', authMiddleware, async (req: Request, res DatabaseService.getInstance().updateNotificationRoute(id, updates); const updated = DatabaseService.getInstance().getNotificationRoute(id); + console.log(`[Routes] Route ${id} updated`); + if (isDebugEnabled()) console.log(`[Routes:diag] Route ${id} update fields: ${Object.keys(updates).filter(k => k !== 'updated_at')}`); res.json(updated); } catch (error) { console.error('Failed to update notification route:', error); @@ -4531,6 +4588,7 @@ app.delete('/api/notification-routes/:id', authMiddleware, (req: Request, res: R const changes = DatabaseService.getInstance().deleteNotificationRoute(id); if (changes === 0) { res.status(404).json({ error: 'Route not found' }); return; } + console.log(`[Routes] Route ${id} deleted`); res.json({ success: true }); } catch (error) { console.error('Failed to delete notification route:', error); @@ -4548,6 +4606,7 @@ app.post('/api/notification-routes/:id/test', authMiddleware, async (req: Reques const route = DatabaseService.getInstance().getNotificationRoute(id); if (!route) { res.status(404).json({ error: 'Route not found' }); return; } + if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${route.channel_url})`); await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url); res.json({ success: true }); } catch (error: unknown) { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index f6806306..e43c56b3 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -109,6 +109,7 @@ export interface NotificationHistory { message: string; timestamp: number; is_read: boolean; + dispatch_error?: string; } export interface FleetSnapshot {