// 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'; // 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 makeApp() { 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' } ]); 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 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: [] }); }); afterEach(() => { nock.cleanAll(); delete process.env.SOFARR_WEBHOOK_SECRET; }); // --------------------------------------------------------------------------- // 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); }); }); 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); }); }); // --------------------------------------------------------------------------- // 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(); }); }); // --------------------------------------------------------------------------- // 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); }); });