mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
e0034132b4
- Add label_ids and categories columns to notification_routes via idempotent migration - Matcher logic always evaluates routes (AND semantics across all non-empty matchers) - getStackLabelIds skips DB call when no enabled route uses label filtering - Extract ALL_NOTIFICATION_CATEGORIES array from NotificationService as single source of truth - Derive VALID_CATEGORIES set from the array in the route handler - Extract validateLabelIds and validateCategories helpers to remove POST/PUT duplication - Extract tryAddColumn as a private DatabaseService class method (removes 5 local re-declarations) - Extract CATEGORY_LABELS to frontend/src/lib/notificationCategories.ts (shared by NotificationPanel and NotificationRoutingSection) - Frontend form adds label and category multiselects with AND-filter hint - Route cards show label and category badges; empty-matcher routes show 'Matches all alerts' - Add tests for category-only, label-only, and combined AND-semantics routing
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
/**
|
|
* 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('accepts 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(201);
|
|
});
|
|
|
|
it('accepts whitespace-only stack patterns, cleaning them to an empty 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(201);
|
|
expect(res.body.stack_patterns).toEqual([]);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|