Compare commits

...

5 Commits

Author SHA1 Message Date
gronod 1ba9d15954 merge branch 'develop' into 'main' - Release v1.7.16
Create Release / release (push) Successful in 21s
Build and Push Docker Image / build (push) Successful in 2m11s
CI / Security audit (push) Successful in 1m57s
CI / Tests & coverage (push) Successful in 2m29s
CI / Swagger Validation & Coverage (push) Successful in 2m6s
2026-05-24 22:13:28 +01:00
gronod 83c9d4d164 fix: blocklist-search queue ID type mismatch and bump version to 1.7.16
Build and Push Docker Image / build (push) Successful in 2m14s
Docs Check / Markdown lint (push) Successful in 2m29s
CI / Security audit (push) Successful in 2m56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m4s
CI / Swagger Validation & Coverage (push) Successful in 3m52s
Docs Check / Mermaid diagram parse check (push) Successful in 4m8s
CI / Tests & coverage (push) Successful in 4m38s
- Cast arrQueueId to String in both sides of the download lookup comparison
  in /api/dashboard/blocklist-search to resolve false-negative match failure
  caused by DOM dataset string vs Radarr/Sonarr API number type mismatch
- Add regression integration test for string-vs-number arrQueueId matching
- Bump version to 1.7.16, update CHANGELOG.md, openapi.yaml, and JSDoc examples

Resolves #48
2026-05-24 22:12:34 +01:00
gronod f2c01903fa fix: resolve Mermaid syntax parse error in ARCHITECTURE.md
Create Release / release (push) Successful in 44s
Docs Check / Markdown lint (push) Successful in 55s
Build and Push Docker Image / build (push) Successful in 57s
Docs Check / Mermaid diagram parse check (push) Successful in 2m39s
CI / Tests & coverage (push) Successful in 2m3s
CI / Security audit (push) Successful in 2m47s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
2026-05-24 21:30:51 +01:00
gronod 8b6ef0f64f merge branch 'develop' into 'main' - Release v1.7.15
Build and Push Docker Image / build (push) Successful in 1m43s
Create Release / release (push) Successful in 39s
CI / Security audit (push) Successful in 1m49s
CI / Swagger Validation & Coverage (push) Successful in 2m0s
CI / Tests & coverage (push) Successful in 2m51s
2026-05-24 21:25:55 +01:00
gronod 7b9c895888 fix: support query parameter-based secret validation fallback to fix Ombi webhooks (#47)
Build and Push Docker Image / build (push) Successful in 2m4s
Docs Check / Markdown lint (push) Successful in 2m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m3s
CI / Security audit (push) Successful in 3m42s
Docs Check / Mermaid diagram parse check (push) Failing after 3m58s
CI / Tests & coverage (push) Successful in 4m21s
CI / Swagger Validation & Coverage (push) Successful in 4m33s
2026-05-24 21:25:38 +01:00
14 changed files with 196 additions and 34 deletions
+8 -7
View File
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
Both endpoints share identical processing logic: Both endpoints share identical processing logic:
``` ```
Sonarr/Radarr Sonarr/Radarr/Ombi
POST /api/webhook/sonarr POST /api/webhook/sonarr
Headers: X-Sofarr-Webhook-Secret: <secret> Headers: X-Sofarr-Webhook-Secret: <secret> OR URL parameter: ?secret=<secret>
Body: { "eventType": "Grab", "instanceName": "Main Sonarr", Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
"date": "2026-05-19T10:00:00.000Z", … } "date": "2026-05-19T10:00:00.000Z", … }
@@ -404,6 +404,7 @@ Sonarr/Radarr
validateWebhookSecret() ──fail──► 401 Unauthorized validateWebhookSecret() ──fail──► 401 Unauthorized
(Checks header or query param)
│ ok │ ok
validatePayload() ──fail──► 400 Bad Request validatePayload() ──fail──► 400 Bad Request
@@ -1002,19 +1003,19 @@ sofarr provides a togglable, real-time log capturing and streaming engine allowi
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph Browser (SPA) subgraph Browser ["Browser (SPA)"]
console["console.log/warn/error"] --> queue["logQueue (batched)"] console["console.log/warn/error"] --> queue["logQueue (batched)"]
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"] queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
end end
subgraph Node.js (Server) subgraph Server ["Node.js (Server)"]
stdout["process.stdout.write"] --> capture["processStreamData()"] stdout["process.stdout.write"] --> capture["processStreamData()"]
stderr["process.stderr.write"] --> capture stderr["process.stderr.write"] --> capture
capture --> |stripAnsi()| serverBuffer["logBuffer (rolling 1000 lines)"] capture --> |stripAnsi| serverBuffer["logBuffer (rolling 1000 lines)"]
capture --> |emit('server-log')| serverSse["GET /api/debug/server-logs (SSE)"] capture --> |emit server-log| serverSse["GET /api/debug/server-logs (SSE)"]
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"] ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
ingestionRoute --> |emit('client-log')| clientSse["GET /api/debug/client-logs (SSE)"] ingestionRoute --> |emit client-log| clientSse["GET /api/debug/client-logs (SSE)"]
end end
``` ```
+16
View File
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.16] - 2026-05-24
### Fixed
- **Blocklist-Search Queue ID Type Mismatch** — Resolved a bug where the "Blocklist and search" action consistently returned `403 Download not found or permission denied` for all users. The server-side download lookup in `server/routes/dashboard.js` used strict equality (`===`) to compare `arrQueueId` values, but the value sent from the SPA client (read from a DOM `data-*` attribute) is always a `string`, while the value populated from the Radarr/Sonarr queue API is a `number`. Both sides are now cast to `String` before comparison, resolving the false-negative match failure. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48).
---
## [1.7.15] - 2026-05-24
### Fixed
- **Ombi Webhook Authentication Failures** — Resolved a critical issue where Ombi webhook notifications failed to authenticate because Ombi's built-in notification agent does not support custom HTTP headers (such as `X-Sofarr-Webhook-Secret`). Added a query parameter authentication fallback (`?secret=`) to all `/api/webhook/*` endpoints (Sonarr, Radarr, and Ombi) and configured Ombi webhook registration to automatically append this secret query parameter. Resolves Gitea Issue [#47](https://git.i3omb.com/Gandalf/sofarr/issues/47).
---
## [1.7.14] - 2026-05-24 ## [1.7.14] - 2026-05-24
### Fixed ### Fixed
+1 -1
View File
@@ -40,7 +40,7 @@ users via Emby. The primary threat surface when exposed to the public internet:
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped | | Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept | | Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push | | Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch | | Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header or `secret` query parameter; 401 on mismatch |
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields | | Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation | | Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` | | Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.14", "version": "1.7.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.14", "version": "1.7.16",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.14", "version": "1.7.16",
"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", "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", "main": "server/index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version: * version:
* type: string * type: string
* description: sofarr version * description: sofarr version
* example: "1.7.14" * example: "1.7.16"
* x-code-samples: * x-code-samples:
* - lang: curl * - lang: curl
* label: cURL * label: cURL
+1 -1
View File
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
* version: * version:
* type: string * type: string
* description: sofarr version * description: sofarr version
* example: "1.7.14" * example: "1.7.16"
*/ */
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version }); res.json({ status: 'ok', uptime: process.uptime(), version });
+25 -4
View File
@@ -22,7 +22,7 @@ info:
## SSE Streaming ## SSE Streaming
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
version: 1.7.14 version: 1.7.16
contact: contact:
name: sofarr name: sofarr
license: license:
@@ -795,8 +795,15 @@ paths:
post: post:
tags: [Webhook] tags: [Webhook]
summary: Sonarr webhook summary: Sonarr webhook
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header. description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
security: [] security: []
parameters:
- name: secret
in: query
required: false
schema:
type: string
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
requestBody: requestBody:
required: true required: true
content: content:
@@ -832,8 +839,15 @@ paths:
post: post:
tags: [Webhook] tags: [Webhook]
summary: Radarr webhook summary: Radarr webhook
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header. description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
security: [] security: []
parameters:
- name: secret
in: query
required: false
schema:
type: string
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
requestBody: requestBody:
required: true required: true
content: content:
@@ -869,8 +883,15 @@ paths:
post: post:
tags: [Webhook] tags: [Webhook]
summary: Ombi webhook summary: Ombi webhook
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header. description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
security: [] security: []
parameters:
- name: secret
in: query
required: false
schema:
type: string
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
requestBody: requestBody:
required: true required: true
content: content:
+5 -2
View File
@@ -686,9 +686,12 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'arrType must be sonarr or radarr' }); return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
} }
// Look up the download to verify permission // Look up the download to verify permission.
// Note: arrQueueId from req.body is always a string (DOM dataset), while
// d.arrQueueId from the Radarr/Sonarr API is a number. Cast both to String
// to avoid a type mismatch causing a false-negative lookup.
const allDownloads = await downloadClientRegistry.getAllDownloads(); const allDownloads = await downloadClientRegistry.getAllDownloads();
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType); const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType);
if (!download) { if (!download) {
console.error('[Blocklist] Download not found:', { arrQueueId, arrType }); console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
+1 -1
View File
@@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
} }
const ombiInst = ombiInstances[0]; const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`; const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
// Call Ombi API to register webhook // Call Ombi API to register webhook
const axios = require('axios'); const axios = require('axios');
+31 -11
View File
@@ -144,13 +144,13 @@ const OMBI_EVENTS = new Set([
]); ]);
/** /**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header * Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
* @param {Object} req - Express request object * @param {Object} req - Express request object
* @returns {boolean} True if secret is valid, false otherwise * @returns {boolean} True if secret is valid, false otherwise
*/ */
function validateWebhookSecret(req) { function validateWebhookSecret(req) {
const expectedSecret = getWebhookSecret(); const expectedSecret = getWebhookSecret();
const providedSecret = req.get('X-Sofarr-Webhook-Secret'); const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
if (!expectedSecret) { if (!expectedSecret) {
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook'); logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
@@ -158,7 +158,7 @@ function validateWebhookSecret(req) {
} }
if (!providedSecret) { if (!providedSecret) {
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header'); logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter');
return false; return false;
} }
@@ -309,13 +309,13 @@ function validatePayload(body) {
* Receives webhook events from Sonarr instances. Validates the secret, logs the event, * Receives webhook events from Sonarr instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing). * refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
* *
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`. * **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Sonarr, not browsers). * No cookie authentication required (webhooks come from Sonarr, not browsers).
* *
* **Rate Limiting:** 60 requests per minute per IP. * **Rate Limiting:** 60 requests per minute per IP.
* *
* **Validation:** * **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header * - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
* - Payload validation (must be JSON object with eventType, instanceName, date) * - Payload validation (must be JSON object with eventType, instanceName, date)
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.) * - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
* - Replay protection: rejects duplicate events within 5-minute window * - Replay protection: rejects duplicate events within 5-minute window
@@ -342,6 +342,13 @@ function validatePayload(body) {
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}` * - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Events: onGrab, onDownload, onUpgrade, onImport * - Events: onGrab, onDownload, onUpgrade, onImport
* security: [] * security: []
* parameters:
* - name: secret
* in: query
* required: false
* schema:
* type: string
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -456,13 +463,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
* Receives webhook events from Radarr instances. Validates the secret, logs the event, * Receives webhook events from Radarr instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing). * refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
* *
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`. * **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Radarr, not browsers). * No cookie authentication required (webhooks come from Radarr, not browsers).
* *
* **Rate Limiting:** 60 requests per minute per IP. * **Rate Limiting:** 60 requests per minute per IP.
* *
* **Validation:** * **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header * - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
* - Payload validation (must be JSON object with eventType, instanceName, date) * - Payload validation (must be JSON object with eventType, instanceName, date)
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.) * - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
* - Replay protection: rejects duplicate events within 5-minute window * - Replay protection: rejects duplicate events within 5-minute window
@@ -489,6 +496,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}` * - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Events: onGrab, onDownload, onUpgrade, onImport * - Events: onGrab, onDownload, onUpgrade, onImport
* security: [] * security: []
* parameters:
* - name: secret
* in: query
* required: false
* schema:
* type: string
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -603,13 +617,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
* Receives webhook events from Ombi instances. Validates the secret, logs the event, * Receives webhook events from Ombi instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing). * refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
* *
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`. * **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Ombi, not browsers). * No cookie authentication required (webhooks come from Ombi, not browsers).
* *
* **Rate Limiting:** 60 requests per minute per IP. * **Rate Limiting:** 60 requests per minute per IP.
* *
* **Validation:** * **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header * - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
* - Payload validation (must be JSON object with notificationType, requestId) * - Payload validation (must be JSON object with notificationType, requestId)
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing) * - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
* - Replay protection: rejects duplicate events within 5-minute window * - Replay protection: rejects duplicate events within 5-minute window
@@ -627,11 +641,17 @@ router.post('/radarr', webhookLimiter, (req, res) => {
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE * 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
* *
* **x-integration-notes:** Configure Ombi webhook: * **x-integration-notes:** Configure Ombi webhook:
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi` * - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
* - Method: POST * - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Application Token: OMBI_API_KEY * - Application Token: OMBI_API_KEY
* security: [] * security: []
* parameters:
* - name: secret
* in: query
* required: false
* schema:
* type: string
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
+32
View File
@@ -972,6 +972,38 @@ describe('POST /api/dashboard/blocklist-search', () => {
expect(res.body.ok).toBe(true); expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore(); mockGetAllDownloads.mockRestore();
}); });
it('matches download correctly when arrQueueId is sent as a string but stored as a number (type mismatch regression)', async () => {
// Regression test for GitHub #48: arrQueueId from the SPA DOM dataset is always
// a string, but the value stored in allDownloads from the Radarr/Sonarr API is a number.
// Without String() casting the === comparison fails and returns 403.
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
// arrQueueId stored as a number (as Radarr API returns it)
{ arrQueueId: 9050001, arrType: 'radarr', importIssues: [], qbittorrent: null }
]);
nock(RADARR_BASE)
.delete('/api/v3/queue/9050001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(RADARR_BASE)
.post('/api/v3/command')
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
// arrQueueId sent as a STRING from the client (as the SPA DOM dataset does)
.send({ arrQueueId: '9050001', arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 77, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+3 -3
View File
@@ -856,7 +856,7 @@ describe('POST /api/ombi/webhook/enable', () => {
.post('/api/v1/Settings/notifications/webhook', { .post('/api/v1/Settings/notifications/webhook', {
id: 42, id: 42,
enabled: true, enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`, webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
applicationToken: 'test-ombi-key' applicationToken: 'test-ombi-key'
}) })
.reply(200, { success: true }); .reply(200, { success: true });
@@ -870,7 +870,7 @@ describe('POST /api/ombi/webhook/enable', () => {
.expect(200); .expect(200);
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi`); expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`);
expect(res.body.applicationToken).toBe('test-ombi-key'); expect(res.body.applicationToken).toBe('test-ombi-key');
}); });
@@ -882,7 +882,7 @@ describe('POST /api/ombi/webhook/enable', () => {
.post('/api/v1/Settings/notifications/webhook', { .post('/api/v1/Settings/notifications/webhook', {
id: 0, id: 0,
enabled: true, enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`, webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
applicationToken: 'test-ombi-key' applicationToken: 'test-ombi-key'
}) })
.reply(200, { success: true }); .reply(200, { success: true });
+69
View File
@@ -156,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
const res = await postSonarr(app, SONARR_GRAB, 'anything'); const res = await postSonarr(app, SONARR_GRAB, 'anything');
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/sonarr?secret=${VALID_SECRET}`)
.send(SONARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/sonarr?secret=wrong-query-secret')
.send(SONARR_GRAB);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
}); });
describe('POST /api/webhook/radarr — secret validation', () => { describe('POST /api/webhook/radarr — secret validation', () => {
@@ -171,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret'); const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/radarr?secret=${VALID_SECRET}`)
.send(RADARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/radarr?secret=wrong-query-secret')
.send(RADARR_GRAB);
expect(res.status).toBe(401);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -548,6 +583,40 @@ describe('POST /api/webhook/ombi', () => {
expect(res.body.error).toBe('Unauthorized'); expect(res.body.error).toBe('Unauthorized');
}); });
it('returns 200 when secret is provided as a query parameter instead of header', 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 res = await request(app)
.post(`/api/webhook/ombi?secret=${VALID_SECRET}`)
.send({
notificationType: 'NewRequest',
requestId: 127,
requestedUser: 'gordon',
title: 'Query Movie',
type: 'Movie',
requestStatus: 'Pending',
applicationUrl: 'https://ombi.test',
requestedDate: '2026-05-23T20:40:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/ombi?secret=wrong-query-secret')
.send({ notificationType: 'NewRequest', requestId: 1 });
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 400 when notificationType is missing or invalid', async () => { it('returns 400 when notificationType is missing or invalid', async () => {
const app = makeApp(); const app = makeApp();
const res = await postOmbi(app, { requestId: 1 }); const res = await postOmbi(app, { requestId: 1 });