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
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:
@@ -41,7 +41,10 @@ tests/
|
||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
└── integration/
|
||||
├── health.test.js # GET /health and /ready endpoints
|
||||
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
|
||||
└── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
|
||||
# replay protection, metrics, security assertions
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
@@ -60,6 +63,7 @@ The tested files meet these per-file minimums (enforced in CI):
|
||||
|---|---|---|
|
||||
| `server/app.js` | 85% | 65% |
|
||||
| `server/routes/auth.js` | 85% | 70% |
|
||||
| `server/routes/webhook.js` | 80% | 70% |
|
||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||
| `server/utils/sanitizeError.js` | 60% | — |
|
||||
| `server/utils/config.js` | 50% | 55% |
|
||||
|
||||
395
tests/integration/webhook.test.js
Normal file
395
tests/integration/webhook.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user