feat(webhooks): security hardening, tests, full documentation audit & polish (Phase 6)
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
Docs Check / Markdown lint (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 1m43s

This commit is contained in:
2026-05-19 17:11:45 +01:00
parent 8609f03c5a
commit 1bef14d590
8 changed files with 888 additions and 22 deletions

View File

@@ -0,0 +1,395 @@
// 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);
});
});