mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(notification-routing): harden with security fixes, validation, and test coverage (#573)
- Add authMiddleware to GET/POST /api/agents (previously unauthenticated) - Add input validation to POST /api/agents (type, URL, enabled) - Add URL host validation via new URL() to notification-routes POST/PUT - Add priority type validation and enabled boolean check to routes - Add name length limit (100 chars) to routes POST/PUT - Add stack pattern dedup and whitespace filtering - Add NaN guard to DELETE /api/notifications/:id - Add dispatch_error to NotificationHistory TypeScript interface - Extract cleanStackPatterns() and validateHttpsUrl() helpers - Add standard and diagnostic logging to agents and routes endpoints - Add comprehensive integration tests for notification-routes CRUD - Add agents auth and validation tests - Add dispatch error recording unit tests
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+85
-26
@@ -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<string, unknown> = { 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) {
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface NotificationHistory {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
is_read: boolean;
|
||||
dispatch_error?: string;
|
||||
}
|
||||
|
||||
export interface FleetSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user