// Copyright (c) 2026 Gordon Bolton. MIT License. /** * Integration tests for webhook endpoints: * POST /api/webhook/sonarr * POST /api/webhook/radarr * * Uses supertest against createApp() (no real server). * processWebhookEvent() makes outbound *arr API calls — those are blocked by * nock so tests remain hermetic (fire-and-forget, not awaited by the handler). * * Covers: * - 401 when X-Sofarr-Webhook-Secret is missing or wrong * - 400 when payload is invalid (missing/unknown eventType, non-object body) * - 200 + { received: true } for valid events * - Replay protection: second identical event returns { duplicate: true } * - Test event (eventType=Test) is accepted and short-circuits the cache refresh * - cache.updateWebhookMetrics is called when a known instance name is provided * - cache.getGlobalWebhookMetrics reflects the recorded event */ import request from 'supertest'; import nock from 'nock'; import { beforeEach, afterEach } from 'vitest'; import { createRequire } from 'module'; import { createApp } from '../../server/app.js'; const require = createRequire(import.meta.url); const cache = require('../../server/utils/cache.js'); const VALID_SECRET = 'test-webhook-secret-abc'; const EMBY_BASE = 'https://emby.test'; const EMBY_AUTH_BODY = { AccessToken: 'test-emby-token-abc123', User: { Id: 'user-id-001', Name: 'TestUser' } }; const EMBY_USER_BODY = { Id: 'user-id-001', Name: 'TestUser', Policy: { IsAdministrator: false } }; // Minimal valid Sonarr Grab payload const SONARR_GRAB = { eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T10:00:00.000Z', series: { id: 1, title: 'Test Show' }, episodes: [{ id: 10, episodeNumber: 1, seasonNumber: 1 }] }; // Minimal valid Radarr Grab payload const RADARR_GRAB = { eventType: 'Grab', instanceName: 'Main Radarr', date: '2026-05-19T10:00:01.000Z', movie: { id: 1, title: 'Test Movie' } }; // Minimal Test event (sent by *arr "Test" button in notifications settings) const SONARR_TEST = { eventType: 'Test', instanceName: 'Main Sonarr', date: '2026-05-19T10:00:02.000Z' }; function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) { nock(EMBY_BASE) .post('/Users/authenticatebyname') .reply(200, EMBY_AUTH_BODY); nock(EMBY_BASE) .get(/\/Users\//) .reply(200, userBody); } async function authenticateUser(app, username = 'TestUser', isAdmin = false) { const userBody = isAdmin ? { ...EMBY_USER_BODY, Policy: { IsAdministrator: true } } : EMBY_USER_BODY; interceptSuccessfulLogin(userBody); const res = await request(app) .post('/api/auth/login') .send({ username, password: 'password' }); const cookies = res.headers['set-cookie']; const csrfToken = res.body.csrfToken; return { cookies, csrfToken }; } function makeApp() { process.env.EMBY_URL = EMBY_BASE; process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET; process.env.SONARR_INSTANCES = JSON.stringify([ { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' } ]); process.env.RADARR_INSTANCES = JSON.stringify([ { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' } ]); process.env.OMBI_INSTANCES = JSON.stringify([ { id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' } ]); return createApp({ skipRateLimits: true }); } function postSonarr(app, payload, secret = VALID_SECRET) { const req = request(app).post('/api/webhook/sonarr').send(payload); if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret); return req; } function postRadarr(app, payload, secret = VALID_SECRET) { const req = request(app).post('/api/webhook/radarr').send(payload); if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret); return req; } beforeEach(() => { // Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget) nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] }); nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] }); nock('https://ombi.test').persist().get(/.*/).reply(200, []); }); afterEach(() => { nock.cleanAll(); delete process.env.EMBY_URL; delete process.env.SOFARR_WEBHOOK_SECRET; delete process.env.SOFARR_BASE_URL; delete process.env.SONARR_INSTANCES; delete process.env.RADARR_INSTANCES; delete process.env.OMBI_INSTANCES; }); // --------------------------------------------------------------------------- // Secret validation // --------------------------------------------------------------------------- describe('POST /api/webhook/sonarr — secret validation', () => { it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => { const app = makeApp(); const res = await postSonarr(app, SONARR_GRAB, null); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); }); it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => { const app = makeApp(); const res = await postSonarr(app, SONARR_GRAB, 'wrong-secret'); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); }); it('returns 401 when SOFARR_WEBHOOK_SECRET is not configured', async () => { delete process.env.SOFARR_WEBHOOK_SECRET; const app = createApp({ skipRateLimits: true }); const res = await postSonarr(app, SONARR_GRAB, 'anything'); expect(res.status).toBe(401); }); it('returns 200 when secret is provided as a query parameter instead of header', async () => { const app = makeApp(); const res = await request(app) .post(`/api/webhook/sonarr?secret=${VALID_SECRET}`) .send(SONARR_GRAB); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('returns 401 when secret is provided as an invalid query parameter', async () => { const app = makeApp(); const res = await request(app) .post('/api/webhook/sonarr?secret=wrong-query-secret') .send(SONARR_GRAB); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); }); }); describe('POST /api/webhook/radarr — secret validation', () => { it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => { const app = makeApp(); const res = await postRadarr(app, RADARR_GRAB, null); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); }); it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => { const app = makeApp(); const res = await postRadarr(app, RADARR_GRAB, 'bad-secret'); expect(res.status).toBe(401); }); it('returns 200 when secret is provided as a query parameter instead of header', async () => { const app = makeApp(); const res = await request(app) .post(`/api/webhook/radarr?secret=${VALID_SECRET}`) .send(RADARR_GRAB); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('returns 401 when secret is provided as an invalid query parameter', async () => { const app = makeApp(); const res = await request(app) .post('/api/webhook/radarr?secret=wrong-query-secret') .send(RADARR_GRAB); expect(res.status).toBe(401); }); }); // --------------------------------------------------------------------------- // Input validation // --------------------------------------------------------------------------- describe('POST /api/webhook/sonarr — input validation', () => { it('returns 400 when body is not a JSON object (array)', async () => { const app = makeApp(); const res = await request(app) .post('/api/webhook/sonarr') .set('X-Sofarr-Webhook-Secret', VALID_SECRET) .send([{ eventType: 'Grab' }]); expect(res.status).toBe(400); }); it('returns 400 when eventType is missing', async () => { const app = makeApp(); const res = await postSonarr(app, { instanceName: 'Main Sonarr' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/eventType/); }); it('returns 400 when eventType is an unknown value', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 'HackerThing', date: '2026-01-01T00:00:00Z' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Unknown eventType/); }); it('returns 400 when eventType is not a string', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 42 }); expect(res.status).toBe(400); }); it('returns 400 when eventType exceeds 64 characters', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 'G'.repeat(65) }); expect(res.status).toBe(400); }); it('returns 400 when instanceName is not a string', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 'Grab', instanceName: 99 }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/instanceName/); }); }); describe('POST /api/webhook/radarr — input validation', () => { it('returns 400 when eventType is missing', async () => { const app = makeApp(); const res = await postRadarr(app, { instanceName: 'Main Radarr' }); expect(res.status).toBe(400); }); it('returns 400 when eventType is unknown', async () => { const app = makeApp(); const res = await postRadarr(app, { eventType: 'Injected', date: '2026-01-01T00:00:00Z' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Unknown eventType/); }); }); // --------------------------------------------------------------------------- // Happy path — valid events // --------------------------------------------------------------------------- describe('POST /api/webhook/sonarr — valid events', () => { it('returns 200 { received: true } for a valid Grab event', async () => { const app = makeApp(); const payload = { ...SONARR_GRAB, date: '2026-05-19T11:00:00.000Z' }; const res = await postSonarr(app, payload); expect(res.status).toBe(200); expect(res.body.received).toBe(true); expect(res.body.duplicate).toBeUndefined(); }); it('returns 200 { received: true } for a Test event', async () => { const app = makeApp(); const payload = { ...SONARR_TEST, date: '2026-05-19T11:01:00.000Z' }; const res = await postSonarr(app, payload); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('accepts DownloadFolderImported event', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 'DownloadFolderImported', instanceName: 'Main Sonarr', date: '2026-05-19T11:02:00.000Z' }); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('accepts event without instanceName field', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 'Grab', date: '2026-05-19T11:03:00.000Z' }); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); }); describe('POST /api/webhook/radarr — valid events', () => { it('returns 200 { received: true } for a valid Grab event', async () => { const app = makeApp(); const payload = { ...RADARR_GRAB, date: '2026-05-19T12:00:00.000Z' }; const res = await postRadarr(app, payload); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('accepts Download event', async () => { const app = makeApp(); const res = await postRadarr(app, { eventType: 'Download', instanceName: 'Main Radarr', date: '2026-05-19T12:01:00.000Z' }); expect(res.status).toBe(200); }); }); // --------------------------------------------------------------------------- // Replay protection // --------------------------------------------------------------------------- describe('Replay protection', () => { it('sonarr: second identical event (same date) returns duplicate:true', async () => { const app = makeApp(); const payload = { eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T13:00:00.000Z' }; const first = await postSonarr(app, payload); expect(first.status).toBe(200); expect(first.body.duplicate).toBeUndefined(); const second = await postSonarr(app, payload); expect(second.status).toBe(200); expect(second.body.duplicate).toBe(true); }); it('sonarr: event with different date is not considered a duplicate', async () => { const app = makeApp(); const first = await postSonarr(app, { eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:00:00.000Z' }); expect(first.body.duplicate).toBeUndefined(); const second = await postSonarr(app, { eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:01:00.000Z' }); expect(second.body.duplicate).toBeUndefined(); }); it('radarr: second identical event returns duplicate:true', async () => { const app = makeApp(); const payload = { eventType: 'Download', instanceName: 'Main Radarr', date: '2026-05-19T15:00:00.000Z' }; await postRadarr(app, payload); const second = await postRadarr(app, payload); expect(second.body.duplicate).toBe(true); }); it('event without date field is never considered a duplicate', async () => { const app = makeApp(); const payload = { eventType: 'Grab', instanceName: 'Main Sonarr' }; const first = await postSonarr(app, payload); const second = await postSonarr(app, payload); // Neither should be flagged as duplicate (no date = no replay key) expect(first.body.duplicate).toBeUndefined(); expect(second.body.duplicate).toBeUndefined(); }); it('sonarr: Test events bypass replay protection and are not flagged as duplicates', async () => { const app = makeApp(); const payload = { eventType: 'Test', instanceName: 'Main Sonarr', date: '2026-05-19T13:00:00.000Z' }; const first = await postSonarr(app, payload); expect(first.status).toBe(200); expect(first.body.duplicate).toBeUndefined(); const second = await postSonarr(app, payload); expect(second.status).toBe(200); expect(second.body.duplicate).toBeUndefined(); }); it('radarr: Test events bypass replay protection and are not flagged as duplicates', async () => { const app = makeApp(); const payload = { eventType: 'Test', instanceName: 'Main Radarr', date: '2026-05-19T13:00:00.000Z' }; const first = await postRadarr(app, payload); expect(first.status).toBe(200); expect(first.body.duplicate).toBeUndefined(); const second = await postRadarr(app, payload); expect(second.status).toBe(200); expect(second.body.duplicate).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // Webhook metrics (Phase 5.1 integration) // --------------------------------------------------------------------------- describe('Webhook metrics — cache.updateWebhookMetrics integration', () => { it('sonarr: increments eventsReceived for a known instance', async () => { const app = makeApp(); const instanceUrl = 'https://sonarr.test'; const before = cache.getWebhookMetrics(instanceUrl); const countBefore = before ? before.eventsReceived : 0; await postSonarr(app, { eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T16:00:00.000Z' }); const after = cache.getWebhookMetrics(instanceUrl); expect(after.eventsReceived).toBe(countBefore + 1); expect(after.lastWebhookTimestamp).toBeGreaterThan(0); }); it('radarr: increments eventsReceived for a known instance', async () => { const app = makeApp(); const instanceUrl = 'https://radarr.test'; const before = cache.getWebhookMetrics(instanceUrl); const countBefore = before ? before.eventsReceived : 0; await postRadarr(app, { eventType: 'Download', instanceName: 'Main Radarr', date: '2026-05-19T16:01:00.000Z' }); const after = cache.getWebhookMetrics(instanceUrl); expect(after.eventsReceived).toBe(countBefore + 1); }); it('does not crash when instanceName does not match a configured instance', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 'Grab', instanceName: 'Unknown Instance', date: '2026-05-19T16:02:00.000Z' }); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('global metrics totalWebhookEventsReceived increments after valid event', async () => { const app = makeApp(); const beforeGlobal = cache.getGlobalWebhookMetrics(); const beforeCount = beforeGlobal.totalWebhookEventsReceived; await postSonarr(app, { eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T17:00:00.000Z' }); const afterGlobal = cache.getGlobalWebhookMetrics(); expect(afterGlobal.totalWebhookEventsReceived).toBe(beforeCount + 1); }); }); // --------------------------------------------------------------------------- // Secret not included in response // --------------------------------------------------------------------------- describe('Security — secret never leaks', () => { it('sonarr: SOFARR_WEBHOOK_SECRET is not present in any response body', async () => { const app = makeApp(); const res = await postSonarr(app, { eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T18:00:00.000Z' }); expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET); }); it('radarr: SOFARR_WEBHOOK_SECRET is not present in 401 response body', async () => { const app = makeApp(); const res = await postRadarr(app, RADARR_GRAB, 'wrong'); expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET); }); }); // --------------------------------------------------------------------------- // GET /api/webhook/config // --------------------------------------------------------------------------- describe('GET /api/webhook/config', () => { it('returns 401 when not authenticated', async () => { const app = makeApp(); const res = await request(app) .get('/api/webhook/config') .expect(401); expect(res.body.error).toBe('Not authenticated'); }); it('returns valid: true when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured', async () => { process.env.EMBY_URL = EMBY_BASE; process.env.SOFARR_BASE_URL = 'https://sofarr.test'; process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET; const app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/webhook/config') .set('Cookie', cookies) .expect(200); expect(res.body.valid).toBe(true); expect(res.body.missing).toEqual([]); }); it('returns valid: false when SOFARR_BASE_URL is missing', async () => { process.env.EMBY_URL = EMBY_BASE; delete process.env.SOFARR_BASE_URL; process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET; const app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/webhook/config') .set('Cookie', cookies) .expect(200); expect(res.body.valid).toBe(false); expect(res.body.missing).toEqual(['SOFARR_BASE_URL']); }); it('returns valid: false when SOFARR_WEBHOOK_SECRET is missing', async () => { process.env.EMBY_URL = EMBY_BASE; process.env.SOFARR_BASE_URL = 'https://sofarr.test'; delete process.env.SOFARR_WEBHOOK_SECRET; const app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/webhook/config') .set('Cookie', cookies) .expect(200); expect(res.body.valid).toBe(false); expect(res.body.missing).toEqual(['SOFARR_WEBHOOK_SECRET']); }); it('returns valid: false when both are missing', async () => { process.env.EMBY_URL = EMBY_BASE; delete process.env.SOFARR_BASE_URL; delete process.env.SOFARR_WEBHOOK_SECRET; const app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/webhook/config') .set('Cookie', cookies) .expect(200); expect(res.body.valid).toBe(false); expect(res.body.missing).toContain('SOFARR_BASE_URL'); expect(res.body.missing).toContain('SOFARR_WEBHOOK_SECRET'); expect(res.body.missing).toHaveLength(2); }); }); // --------------------------------------------------------------------------- // Ombi webhook receiver // --------------------------------------------------------------------------- describe('POST /api/webhook/ombi', () => { function postOmbi(app, payload, secret = VALID_SECRET) { const req = request(app).post('/api/webhook/ombi').send(payload); if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret); return req; } it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => { const app = makeApp(); const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, null); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); }); it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => { const app = makeApp(); const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, 'wrong-secret'); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); }); it('returns 200 when secret is provided as a query parameter instead of header', async () => { const app = makeApp(); nock('https://ombi.test') .get('/api/v1/Request/movie') .reply(200, []); nock('https://ombi.test') .get('/api/v1/Request/tv') .reply(200, []); const res = await request(app) .post(`/api/webhook/ombi?secret=${VALID_SECRET}`) .send({ notificationType: 'NewRequest', requestId: 127, requestedUser: 'gordon', title: 'Query Movie', type: 'Movie', requestStatus: 'Pending', applicationUrl: 'https://ombi.test', requestedDate: '2026-05-23T20:40:00.000Z' }); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('returns 401 when secret is provided as an invalid query parameter', async () => { const app = makeApp(); const res = await request(app) .post('/api/webhook/ombi?secret=wrong-query-secret') .send({ notificationType: 'NewRequest', requestId: 1 }); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); }); it('returns 400 when notificationType is missing or invalid', async () => { const app = makeApp(); const res = await postOmbi(app, { requestId: 1 }); expect(res.status).toBe(400); expect(res.body.error).toBe('Invalid or missing notificationType'); }); it('returns 400 when notificationType is unknown', async () => { const app = makeApp(); const res = await postOmbi(app, { notificationType: 'UnknownNotification', requestId: 1 }); expect(res.status).toBe(400); expect(res.body.error).toBe('Invalid or missing notificationType'); }); it('returns 200 { received: true } for a valid NewRequest event', async () => { const app = makeApp(); // Nock requests endpoint since processWebhookEvent will fetch requests nock('https://ombi.test') .get('/api/v1/Request/movie') .reply(200, []); nock('https://ombi.test') .get('/api/v1/Request/tv') .reply(200, []); const payload = { notificationType: 'NewRequest', requestId: 123, requestedUser: 'gordon', title: 'New Movie', type: 'Movie', requestStatus: 'Pending', applicationUrl: 'https://ombi.test', requestedDate: '2026-05-23T20:30:00.000Z' }; const res = await postOmbi(app, payload); expect(res.status).toBe(200); expect(res.body.received).toBe(true); expect(res.body.duplicate).toBeUndefined(); }); it('returns 200 { received: true } for a valid RequestAvailable event', async () => { const app = makeApp(); nock('https://ombi.test') .get('/api/v1/Request/movie') .reply(200, []); nock('https://ombi.test') .get('/api/v1/Request/tv') .reply(200, []); const payload = { notificationType: 'RequestAvailable', requestId: 124, requestedUser: 'gordon', title: 'Available Movie', type: 'Movie', requestStatus: 'Available', applicationUrl: 'https://ombi.test', requestedDate: '2026-05-23T20:31:00.000Z' }; const res = await postOmbi(app, payload); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it('returns duplicate: true for a replay of the same event', async () => { const app = makeApp(); nock('https://ombi.test').persist() .get('/api/v1/Request/movie') .reply(200, []); nock('https://ombi.test').persist() .get('/api/v1/Request/tv') .reply(200, []); const payload = { notificationType: 'NewRequest', requestId: 125, requestedUser: 'gordon', title: 'New Movie', type: 'Movie', requestStatus: 'Pending', applicationUrl: 'https://ombi.test', requestedDate: '2026-05-23T20:32:00.000Z' }; // First request const res1 = await postOmbi(app, payload); expect(res1.status).toBe(200); expect(res1.body.duplicate).toBeUndefined(); // Replay const res2 = await postOmbi(app, payload); expect(res2.status).toBe(200); expect(res2.body.duplicate).toBe(true); }); it('returns 200 { received: true } for a valid NewRequest event with PascalCase payload', async () => { const app = makeApp(); nock('https://ombi.test') .get('/api/v1/Request/movie') .reply(200, []); nock('https://ombi.test') .get('/api/v1/Request/tv') .reply(200, []); const payload = { NotificationType: 'NewRequest', RequestId: 126, RequestedUser: { UserName: 'gordon_pascal' }, Title: 'Pascal Movie', Type: 'Movie', RequestStatus: 'Pending', ApplicationUrl: 'https://ombi.test', RequestedDate: '2026-05-23T20:33:00.000Z' }; const res = await postOmbi(app, payload); expect(res.status).toBe(200); expect(res.body.received).toBe(true); expect(res.body.duplicate).toBeUndefined(); }); });