From 82b38246588cf221b95b73f94bfbbb263b61f1ff Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 23 May 2026 20:38:05 +0100 Subject: [PATCH] chore: bump version to 1.7.7 and update CHANGELOG --- CHANGELOG.md | 9 ++ package-lock.json | 4 +- package.json | 2 +- server/routes/webhook.js | 3 +- tests/integration/webhook.test.js | 133 +++++++++++++++++++++++++++++- 5 files changed, 146 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a6be8..50aa8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.7] - 2026-05-23 + +### Fixed + +- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42). +- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers. + +--- + ## [1.7.6] - 2026-05-23 ### Fixed diff --git a/package-lock.json b/package-lock.json index b523091..0a1cb75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.6", + "version": "1.7.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.6", + "version": "1.7.7", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index c8297ee..3fbb42f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.6", + "version": "1.7.7", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { diff --git a/server/routes/webhook.js b/server/routes/webhook.js index 1283f19..68d5d23 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -87,7 +87,7 @@ const VALID_EVENT_TYPES = new Set([ 'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete', 'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored', // Ombi notification types - 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing' + 'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing' ]); // Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys. @@ -135,6 +135,7 @@ const HISTORY_EVENTS = new Set([ // Ombi event types — all Ombi events refresh the requests cache const OMBI_EVENTS = new Set([ + 'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', diff --git a/tests/integration/webhook.test.js b/tests/integration/webhook.test.js index 6e81a4e..504b494 100644 --- a/tests/integration/webhook.test.js +++ b/tests/integration/webhook.test.js @@ -97,6 +97,9 @@ function makeApp() { 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 }); } @@ -113,9 +116,10 @@ function postRadarr(app, payload, secret = VALID_SECRET) { } beforeEach(() => { - // Block outbound *arr calls made by processWebhookEvent (fire-and-forget) + // 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(() => { @@ -125,6 +129,7 @@ afterEach(() => { delete process.env.SOFARR_BASE_URL; delete process.env.SONARR_INSTANCES; delete process.env.RADARR_INSTANCES; + delete process.env.OMBI_INSTANCES; }); // --------------------------------------------------------------------------- @@ -518,3 +523,129 @@ describe('GET /api/webhook/config', () => { 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 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); + }); +}); +