chore: bump version to 1.7.7 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m17s
Docs Check / Markdown lint (push) Successful in 2m27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m44s
CI / Security audit (push) Successful in 3m10s
Docs Check / Mermaid diagram parse check (push) Successful in 3m54s
CI / Tests & coverage (push) Successful in 4m6s
CI / Swagger Validation & Coverage (push) Successful in 4m23s

This commit is contained in:
2026-05-23 20:38:05 +01:00
parent 49e3261b59
commit 82b3824658
5 changed files with 146 additions and 5 deletions
+9
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+2 -1
View File
@@ -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',
+132 -1
View File
@@ -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);
});
});