diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa4784..5304be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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.17] - 2026-05-24 + +### Fixed + +- **Blocklist-Search Persistent Failure (Regression from v1.7.16)** — Identified and corrected the true root cause of the blocklist-and-search feature being non-functional. The v1.7.16 fix correctly cast both sides of the queue ID comparison to `String`, but the lookup was performed against `downloadClientRegistry.getAllDownloads()`, which returns **raw download-client data** (qBittorrent, SABnzbd, etc.) that never has `arrQueueId` populated — that field is only assigned by `DownloadMatcher.js` during the SSE build phase from the *arr cache. For qBittorrent torrents specifically, `QBittorrentClient.normalizeDownload()` does not set `arrQueueId` at all, so the lookup always returned `undefined` and the request was rejected with `403`. The permission check in `POST /api/dashboard/blocklist-search` now looks up the queue record directly from the Sonarr/Radarr queue cache (`poll:sonarr-queue` / `poll:radarr-queue`) where `record.id` is the numeric queue ID, using `String()` casting on both sides to handle the DOM-dataset (string) vs API response (number) type difference. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48) (regression). + +--- + ## [1.7.16] - 2026-05-24 ### Fixed diff --git a/package-lock.json b/package-lock.json index 09d8e87..4186396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.16", + "version": "1.7.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.16", + "version": "1.7.17", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 1d6ae3b..586dc92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.16", + "version": "1.7.17", "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/scratch/blocklist_issue.txt b/scratch/blocklist_issue.txt new file mode 100644 index 0000000..f70763c --- /dev/null +++ b/scratch/blocklist_issue.txt @@ -0,0 +1,21 @@ +Problem: +The "Blocklist & Search" button on download cards fails with a "400 Bad Request (Missing required fields)" when clicked on any television release in Sonarr that represents a full season package or a multi-episode release. + +Root Cause: +1. In `server/services/DownloadMatcher.js`, when a download is matched with a Sonarr queue record, `arrContentId` is populated with `sonarrMatch.episodeId || null`. +2. However, for multi-episode packs or full season grabs in Sonarr v3, the `episodeId` field is missing from the queue record payload (since the release is associated with multiple episodes). Instead, Sonarr provides an `episodeIds` array. As a result, `arrContentId` is normalized to `null`. +3. When the user clicks the "Blocklist & Search" button in the UI, the frontend calls the `POST /api/dashboard/blocklist-search` endpoint. The request body includes `arrContentId: null`. +4. The backend route validator in `server/routes/dashboard.js` strictly requires all fields including `arrContentId` to be truthy: + ```javascript + if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) { + return res.status(400).json({ error: 'Missing required fields' }); + } + ``` + Because `arrContentId` is `null`, this check fails and returns `400 Missing required fields`, completely blocking the blocklist operation (even though queue removal itself does not require an episode ID). +5. Furthermore, the search trigger logic in `dashboard.js` only handles single episode searches via `{ name: 'EpisodeSearch', episodeIds: [arrContentId] }` and has no logic to handle `episodeIds` arrays or fallback searches (such as `SeriesSearch` or `SeasonSearch`). + +Proposed Fix: +1. **Relax Backend Validation**: Allow `arrContentId` to be optional or null for `sonarr` queue records to ensure the deletion and blocklist steps can still execute. +2. **Robust Search Triggers**: + - If `episodeId` is missing but `episodeIds` array is available on the matched record, pass the array of IDs to the frontend/backend. + - Modify the `dashboard.js` re-search block to support `EpisodeSearch` with multiple IDs, or fall back to triggering a `SeriesSearch` command using the `seriesId` if no specific episode IDs are resolved. diff --git a/scratch/client_log_feature.txt b/scratch/client_log_feature.txt new file mode 100644 index 0000000..b624312 --- /dev/null +++ b/scratch/client_log_feature.txt @@ -0,0 +1,16 @@ +Title: +FEATURE: Client-side console log capturing and streaming API endpoint with dual-authentication + +Problem / Requirement: +To aid in frontend troubleshooting, developers need a way to capture and gather client-side console logs (`console.log`, `console.warn`, `console.error`) and make them accessible over a real-time log stream endpoint. This helps debug frontend issues (such as SSE failures, CSP violations, and state synchronization issues) in environments without direct access to browser devtools. + +Success Criteria: +1. Client-Side Interceptor: Intercept standard browser console methods at SPA startup and place captured logs into an in-memory queue. +2. Batched Log Transmission (Selected Option A): Periodic HTTP POST batch queries to `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize browser thread and network overhead. +3. Server storage and SSE log streaming: + - Save incoming logs into a separate rolling 1000-line buffer `clientLogBuffer`. + - Expose `GET /api/debug/client-logs/stream` to stream client-side logs in real-time via SSE. +4. Security & Configuration: + - Enableable only when the environment variable `ENABLE_LOG_STREAM=true` is set. + - Enforce exact same dual-auth rules (Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass) on both client logs endpoints. +5. API Documentation: Documented in `server/openapi.yaml`. diff --git a/scratch/client_logging_options.txt b/scratch/client_logging_options.txt new file mode 100644 index 0000000..5390318 --- /dev/null +++ b/scratch/client_logging_options.txt @@ -0,0 +1,12 @@ +Amended the plan to add client-side console log capturing and streaming options: + +### Proposed Client Logging Design: +- **Client-Side Capture (Frontend Interception)**: Hook into standard browser console methods (`console.log`, `console.warn`, `console.error`) at client-side startup. +- **Client-to-Server Transmission**: + - **Option A (Recommended)**: Store captured logs in a local memory queue, and periodically perform a batched `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize network overhead. + - **Option B (WebSocket Channel)**: Stream logs instantly via persistent WebSockets, which adds structural and connection management complexity. +- **Server Storage & SSE Streaming**: + - Store incoming client logs in a separate rolling 1000-line buffer `clientLogBuffer`. + - Expose `GET /api/debug/client-logs/stream` (under the exact same dual-auth/webhook-secret constraints) to stream client-side logs in real-time via SSE to debugging tools. + +The `implementation_plan.md` artifact has been successfully updated with these options. diff --git a/scratch/fetch-remote-logs.js b/scratch/fetch-remote-logs.js new file mode 100644 index 0000000..119aad7 --- /dev/null +++ b/scratch/fetch-remote-logs.js @@ -0,0 +1,32 @@ +const axios = require('axios'); +const fs = require('fs'); + +const secret = '63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a'; +const serverLogsUrl = 'https://sofarr.i3omb.com/api/debug/server-logs?testClose=true'; +const clientLogsUrl = 'https://sofarr.i3omb.com/api/debug/client-logs?testClose=true'; + +async function fetchLogs(url, filename) { + console.log(`Fetching logs from ${url}...`); + try { + const response = await axios.get(url, { + headers: { + 'x-webhook-secret': secret + } + }); + fs.writeFileSync(filename, response.data); + console.log(`Logs saved to ${filename} (${response.data.length} bytes).`); + } catch (err) { + console.error(`Failed to fetch from ${url}:`, err.message); + if (err.response) { + console.error(`Status: ${err.response.status}`); + console.error(`Body:`, JSON.stringify(err.response.data)); + } + } +} + +async function run() { + await fetchLogs(serverLogsUrl, 'scratch/remote_server.log'); + await fetchLogs(clientLogsUrl, 'scratch/remote_client.log'); +} + +run(); diff --git a/scratch/issue48_reopen_comment.txt b/scratch/issue48_reopen_comment.txt new file mode 100644 index 0000000..1a57bb4 --- /dev/null +++ b/scratch/issue48_reopen_comment.txt @@ -0,0 +1,26 @@ +## Regression: Fix in v1.7.16 was insufficient — issue persists in production + +### Updated Root Cause Analysis + +Post-release investigation of live server debug logs on `sofarr.i3omb.com` confirms the blocklist feature is **still failing** after v1.7.16. The server logs still show: + +``` +[Blocklist] Download not found: { arrQueueId: 439913856, arrType: 'radarr' } +``` + +The v1.7.16 fix cast both sides of the comparison to `String`, which was the correct approach — but it was applied to the **wrong data source**. + +The permission check at line 693 of `dashboard.js` calls: + +```js +const allDownloads = await downloadClientRegistry.getAllDownloads(); +const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType); +``` + +`downloadClientRegistry.getAllDownloads()` fetches **raw download client data** directly from qBittorrent, SABnzbd, etc. — these are unmatched objects with no Sonarr/Radarr queue metadata. The `arrQueueId` field is only populated during `DownloadMatcher.js` processing (which runs during the SSE/dashboard build from the *arr cache). Because qBittorrent's `normalizeDownload()` never sets `arrQueueId`, the lookup **always returns `undefined`** for any qBittorrent torrent, regardless of type casting. + +### Correct Fix + +The permission check should validate against the **Sonarr/Radarr queue cache records** directly (where `id` is the queue record ID), rather than against raw download client data. The fix will replace the `downloadClientRegistry.getAllDownloads()` lookup with a direct cache lookup of `poll:sonarr-queue` / `poll:radarr-queue` records, matching by `String(record.id) === String(arrQueueId)`. + +This will be released in v1.7.17. diff --git a/scratch/issue_blocklist.txt b/scratch/issue_blocklist.txt new file mode 100644 index 0000000..32473ee --- /dev/null +++ b/scratch/issue_blocklist.txt @@ -0,0 +1,46 @@ +## Summary + +The "Blocklist and search" feature is broken for all users. Clicking the blocklist button on a download (e.g. the film "Project Hail Mary", `arrQueueId: 905000340`, `arrType: radarr`) consistently returns a `403 Download not found or permission denied` error. + +## Root Cause + +The server-side lookup in `server/routes/dashboard.js` uses strict equality (`===`) to find the matching download: + +```js +const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType); +``` + +- `d.arrQueueId` is populated from the Radarr/Sonarr queue API response as a **number** (e.g. `905000340`). +- `arrQueueId` from `req.body` originates from the client SPA via a DOM `dataset` attribute, which is always a **string** (e.g. `"905000340"`). +- Due to the type mismatch, `905000340 === "905000340"` evaluates to `false`, so the lookup always fails and returns `403`. + +## Evidence + +Server log (live environment, `2026-05-24`): + +``` +[Blocklist] Download not found: { arrQueueId: 905000340, arrType: 'radarr' } +``` + +Client log confirms user clicked blocklist at `21:01:19`, `21:01:32`, and `21:02:35`. + +## Steps to Reproduce + +1. Open the dashboard on a Radarr or Sonarr download with a pending queue entry. +2. Click the "Blocklist and search" button. +3. The action silently fails; the download is not removed and no re-search is triggered. +4. Server logs show `[Blocklist] Download not found`. + +## Proposed Fix + +Cast both sides of the comparison to `String` before comparing: + +```js +const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType); +``` + +This fix will be released in version `1.7.16`. + +## Severity + +**High** — The blocklist-and-search feature is completely non-functional for all users. There is no workaround within the UI. diff --git a/scratch/log_stream_feature.txt b/scratch/log_stream_feature.txt new file mode 100644 index 0000000..9883df4 --- /dev/null +++ b/scratch/log_stream_feature.txt @@ -0,0 +1,19 @@ +Title: +FEATURE: Log streaming debug endpoint with dual-authentication and togglable runtime configuration + +Problem / Requirement: +Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access. + +Success Criteria: +1. **Lightweight Log Streaming**: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory. +2. **Dual-Authentication**: + - Accepts existing session cookie (`emby_user`) with administrative credentials. + - Accepts standard HTTP Basic Authentication (`Authorization: Basic `) using Emby administrator username/password credentials. +3. **Runtime Configuration Toggle**: Enableable using a runtime environment variable `ENABLE_LOG_STREAM=true` (defaulting to `false`/disabled). When disabled, returns a `403 Forbidden` response. +4. **API Spec Documentation**: Documented in `server/openapi.yaml` under the `/api/debug/logs` endpoint, including the query format and response schemas. + +Proposed Implementation: +1. **Log Interceptor**: Implement a global stdout/stderr hook in `server/index.js` or in a new `server/utils/logCapture.js` to collect a rolling buffer of 1000 log lines and expose a Node `EventEmitter` to push new logs to active subscribers. +2. **Authentication Middleware**: Create `server/middleware/logStreamAuth.js` which verifies active sessions or fallback Basic Auth headers by calling Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to verify the user is a valid administrator. +3. **Route Definition**: Define `server/routes/debug.js` to register `GET /api/debug/logs` backing the SSE stream, enforce the `ENABLE_LOG_STREAM === 'true'` check, and execute `logStreamAuth` checks. +4. **OpenAPI Spec Integration**: Define `/api/debug/logs` schemas, parameters, security schemes, and basic auth descriptions inside `server/openapi.yaml`. diff --git a/scratch/ombi_bug_desc.txt b/scratch/ombi_bug_desc.txt new file mode 100644 index 0000000..0cef067 --- /dev/null +++ b/scratch/ombi_bug_desc.txt @@ -0,0 +1,21 @@ +### Bug Description +Ombi webhooks are currently failing to authenticate. In `server/routes/webhook.js`, all `/api/webhook/*` endpoints (sonarr, radarr, and ombi) require the custom `X-Sofarr-Webhook-Secret` HTTP header to be present and match the configured `SOFARR_WEBHOOK_SECRET`. + +However, Ombi's built-in Webhook notification agent does not support adding custom HTTP headers to its outgoing webhook notification requests. This makes it impossible for Ombi to successfully authenticate using the current header-only validation mechanism. + +### Root Cause +In `server/routes/webhook.js`, `validateWebhookSecret(req)` only inspects `req.get('X-Sofarr-Webhook-Secret')`: +```javascript +function validateWebhookSecret(req) { + const expectedSecret = getWebhookSecret(); + const providedSecret = req.get('X-Sofarr-Webhook-Secret'); + ... +} +``` +Since Ombi sends standard JSON payloads to a configured URL without custom headers, it cannot supply this header, resulting in a `401 Unauthorized` response. + +### Proposed Remediation +1. **Fallback Authentication Method**: Update `validateWebhookSecret(req)` in `server/routes/webhook.js` to look for the secret in either the `X-Sofarr-Webhook-Secret` header OR as a `secret` query parameter (`req.query.secret`). +2. **Registration Update**: Update the `/webhook/enable` route in `server/routes/ombi.js` to automatically append `?secret=${webhookSecret}` to the registered `webhookUrl` sent to Ombi. +3. **OpenAPI Spec & JSDoc Updates**: Document the query-parameter fallback authentication option in `server/openapi.yaml` and the `@openapi` JSDoc comments in `server/routes/webhook.js`. +4. **Integration Testing**: Add new integration tests in `tests/integration/webhook.test.js` to assert that authentication via query parameters succeeds, and that invalid query parameters are rejected. diff --git a/scratch/plan_amendment.txt b/scratch/plan_amendment.txt new file mode 100644 index 0000000..527d2ad --- /dev/null +++ b/scratch/plan_amendment.txt @@ -0,0 +1,6 @@ +Amended the plan to include a high-priority bypass using the `X-Webhook-Secret` request header: + +1. **Webhook Secret Bypass**: If the request contains the `X-Webhook-Secret` header, we verify if it matches the configured `SOFARR_WEBHOOK_SECRET` environment variable. +2. **Access Granted**: If matching, the request is immediately authorized, completely bypassing session and Emby Basic Auth checks. This is ideal for curl scripts, server-to-server monitoring, or external debugging logs captures. + +I have updated the `implementation_plan.md` artifact to reflect this amendment. diff --git a/scratch/plan_comment.txt b/scratch/plan_comment.txt new file mode 100644 index 0000000..9849bbd --- /dev/null +++ b/scratch/plan_comment.txt @@ -0,0 +1,15 @@ +I have investigated the blocklist & search failure reported in this issue and created a technical remediation plan: + +### Root Cause +For television grabs representing a full-season pack or multi-episode package in Sonarr, the `episodeId` property is absent (instead, it has an `episodeIds` array). This maps to a `null` value for `arrContentId` on the client download card. The `/api/dashboard/blocklist-search` route strictly requires all fields including `arrContentId` to be truthy, returning `400 Bad Request: Missing required fields` and completely blocking the queue blocklist/removal action. + +### Remediation Plan +1. **Enrich Backend Match Data**: Expose `arrContentIds` (`sonarrMatch.episodeIds`) and `arrSeriesId` (`sonarrMatch.seriesId`) from `DownloadMatcher.js` to the normalized download card object. +2. **Relax API Route Validation**: Remove `arrContentId` from the mandatory request parameters check in `server/routes/dashboard.js`. +3. **Enhance Search Commands**: + - If a single `arrContentId` is provided, trigger `EpisodeSearch` for that single ID. + - If an `arrContentIds` array is provided, trigger `EpisodeSearch` with that list of IDs. + - If no specific episode IDs can be resolved but `arrSeriesId` is provided, fall back to triggering a series-wide `SeriesSearch`. +4. **Update Frontend & Documentation**: Update the client payload, update the OpenAPI spec, and add integration tests covering single/multi/fallback searches. + +Upon approval, I will execute this plan, merge to `main`, close this ticket referencing the resolving commit, and cut a new point release (v1.7.11). diff --git a/scratch/server_log_feature.txt b/scratch/server_log_feature.txt new file mode 100644 index 0000000..bcebf1f --- /dev/null +++ b/scratch/server_log_feature.txt @@ -0,0 +1,14 @@ +Title: +FEATURE: Togglable server-side (Docker) log streaming debug endpoint with dual-authentication + +Problem / Requirement: +Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access. + +Success Criteria: +1. Lightweight Log Streaming: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory. +2. Dual-Authentication with Webhook Secret Bypass: + - Accepts existing session cookie (emby_user) with administrative credentials. + - Accepts standard HTTP Basic Authentication (Authorization: Basic ) using Emby administrator username/password credentials. + - Accepts X-Webhook-Secret header matching the SOFARR_WEBHOOK_SECRET environment variable for programmatic bypass. +3. Runtime Configuration Toggle: Enableable using a runtime environment variable ENABLE_LOG_STREAM=true (defaulting to false/disabled). When disabled, returns a 403 Forbidden response. +4. API Spec Documentation: Documented in server/openapi.yaml under the /api/debug/logs endpoint, including the query format and response schemas. diff --git a/scratch/tag_msg.txt b/scratch/tag_msg.txt new file mode 100644 index 0000000..61c477a --- /dev/null +++ b/scratch/tag_msg.txt @@ -0,0 +1,9 @@ +Release v1.7.16 + +Remediate the blocklist-search queue ID type mismatch. The "Blocklist and +search" action was returning 403 for all users because the arrQueueId +comparison used strict equality between a string (from the SPA DOM dataset) +and a number (from the Radarr/Sonarr API). Both values are now cast to +String before comparison. + +See CHANGELOG.md for full details. diff --git a/scratch/webhook_secret.txt b/scratch/webhook_secret.txt new file mode 100644 index 0000000..86fba6c --- /dev/null +++ b/scratch/webhook_secret.txt @@ -0,0 +1 @@ +63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a diff --git a/server/openapi.yaml b/server/openapi.yaml index 2983106..4a49524 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -22,7 +22,7 @@ info: ## SSE Streaming Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. - version: 1.7.16 + version: 1.7.17 contact: name: sofarr license: diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index c07b07d..74e3e6e 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -686,20 +686,33 @@ router.post('/blocklist-search', requireAuth, async (req, res) => { return res.status(400).json({ error: 'arrType must be sonarr or radarr' }); } - // 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 download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType); + // Look up the queue record directly from the *arr cache. + // downloadClientRegistry.getAllDownloads() returns raw download-client data + // (qBittorrent, SABnzbd, etc.) which never has arrQueueId set — that field + // is only populated later by DownloadMatcher during the SSE build phase. + // Instead, we verify permission by finding the record in the Sonarr/Radarr + // queue cache where record.id is the numeric queue ID. + // Cast both sides to String to handle the DOM dataset → string vs API → number mismatch. + const queueCacheKey = arrType === 'sonarr' ? 'poll:sonarr-queue' : 'poll:radarr-queue'; + const queueData = cache.get(queueCacheKey) || { records: [] }; + const queueRecord = (queueData.records || []).find(r => r.id != null && String(r.id) === String(arrQueueId)); - if (!download) { - console.error('[Blocklist] Download not found:', { arrQueueId, arrType }); + if (!queueRecord) { + console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType }); return res.status(403).json({ error: 'Download not found or permission denied' }); } + // Build a minimal download-like object for canBlocklist eligibility check. + // Includes importIssues so non-admins can blocklist stalled/import-pending items. + const importIssues = require('../services/DownloadAssembler').getImportIssues(queueRecord); + const downloadForCheck = { + importIssues: importIssues || [], + arrQueueId: queueRecord.id, + arrType + }; + // Check if user can blocklist this download - if (!canBlocklist(download, user.isAdmin)) { + if (!canBlocklist(downloadForCheck, user.isAdmin)) { console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType }); return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' }); } diff --git a/tests/integration/dashboard.test.js b/tests/integration/dashboard.test.js index f76ae9b..032e1da 100644 --- a/tests/integration/dashboard.test.js +++ b/tests/integration/dashboard.test.js @@ -225,7 +225,7 @@ function invalidatePollCache() { 'poll:sab-queue', 'poll:sab-history', 'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags', 'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags', - 'poll:qbittorrent' + 'poll:qbittorrent', 'poll:ombi-requests' ]; for (const k of keys) cache.invalidate(k); } @@ -749,12 +749,14 @@ describe('POST /api/dashboard/blocklist-search', () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf } = await loginAs(app); const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); - - // Mock getAllDownloads to return a download that doesn't qualify for blocklist - const downloadClientRegistry = require('../../server/utils/downloadClients'); - const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([ - { arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null } - ]); + + // Seed cache: queue record exists but has no import issues (non-admin cannot blocklist) + cache.set('poll:sonarr-queue', { records: [{ + id: 1, + title: 'My.Show.S01E01.720p', + trackedDownloadState: 'downloading', + trackedDownloadStatus: 'ok' + }] }, CACHE_TTL); const res = await request(app) .post('/api/dashboard/blocklist-search') @@ -763,18 +765,14 @@ describe('POST /api/dashboard/blocklist-search', () => { .send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' }); expect(res.status).toBe(403); expect(res.body.error).toMatch(/permission denied/i); - mockGetAllDownloads.mockRestore(); }); - it('returns 403 for non-admin when download not found in active downloads', async () => { + it('returns 403 for non-admin when download not found in arr queue cache', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf } = await loginAs(app); const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); - - // Mock getAllDownloads to return empty array (download not found) - const downloadClientRegistry = require('../../server/utils/downloadClients'); - const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]); + // Cache is already seeded empty by beforeEach; no queue record with id=1 exists const res = await request(app) .post('/api/dashboard/blocklist-search') .set('Cookie', [...cookies, csrfCookie].join('; ')) @@ -782,19 +780,21 @@ describe('POST /api/dashboard/blocklist-search', () => { .send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' }); expect(res.status).toBe(403); expect(res.body.error).toMatch(/download not found/i); - mockGetAllDownloads.mockRestore(); }); it('returns 200 for non-admin with import issues (qualifying condition)', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf } = await loginAs(app); const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); - - // Mock getAllDownloads to return a download with import issues (qualifies for blocklist) - const downloadClientRegistry = require('../../server/utils/downloadClients'); - const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([ - { arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null } - ]); + + // Seed cache: queue record with import issues — qualifies non-admin for blocklist + cache.set('poll:sonarr-queue', { records: [{ + id: 1, + title: 'My.Show.S01E01.720p', + trackedDownloadState: 'importPending', + trackedDownloadStatus: 'warning', + statusMessages: [{ messages: ['Import error 1'] }] + }] }, CACHE_TTL); // Mock Sonarr DELETE and command endpoints nock(SONARR_BASE) @@ -812,7 +812,6 @@ describe('POST /api/dashboard/blocklist-search', () => { .send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); - mockGetAllDownloads.mockRestore(); }); it('returns 400 when required fields are missing', async () => { @@ -843,11 +842,8 @@ describe('POST /api/dashboard/blocklist-search', () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getAuthHeaders(app); - // Mock getAllDownloads to return a matching download for admin - const downloadClientRegistry = require('../../server/utils/downloadClients'); - const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([ - { arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null } - ]); + // Seed the Sonarr queue cache so the permission lookup finds the record + cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL); nock(SONARR_BASE) .delete('/api/v3/queue/1001') @@ -864,18 +860,14 @@ describe('POST /api/dashboard/blocklist-search', () => { .send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); - mockGetAllDownloads.mockRestore(); }); it('calls Radarr DELETE+command and returns ok:true', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getAuthHeaders(app); - // Mock getAllDownloads to return a matching download for admin - const downloadClientRegistry = require('../../server/utils/downloadClients'); - const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([ - { arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null } - ]); + // Seed the Radarr queue cache so the permission lookup finds the record + cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL); nock(RADARR_BASE) .delete('/api/v3/queue/2001') @@ -892,18 +884,14 @@ describe('POST /api/dashboard/blocklist-search', () => { .send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); - mockGetAllDownloads.mockRestore(); }); it('returns 502 when Sonarr DELETE request fails', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getAuthHeaders(app); - // Mock getAllDownloads to return a matching download for admin - const downloadClientRegistry = require('../../server/utils/downloadClients'); - const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([ - { arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null } - ]); + // Seed the Sonarr queue cache so the permission lookup finds the record + cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL); nock(SONARR_BASE) .delete('/api/v3/queue/1001') @@ -916,17 +904,14 @@ describe('POST /api/dashboard/blocklist-search', () => { .set('X-CSRF-Token', csrf) .send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' }); expect(res.status).toBe(502); - mockGetAllDownloads.mockRestore(); }); it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => { 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: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null } - ]); + // Seed the Sonarr queue cache so the permission lookup finds the record + cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL); nock(SONARR_BASE) .delete('/api/v3/queue/1001') @@ -943,17 +928,14 @@ describe('POST /api/dashboard/blocklist-search', () => { .send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); - mockGetAllDownloads.mockRestore(); }); it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => { 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: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null } - ]); + // Seed the Sonarr queue cache so the permission lookup finds the record + cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL); nock(SONARR_BASE) .delete('/api/v3/queue/1001') @@ -970,21 +952,25 @@ describe('POST /api/dashboard/blocklist-search', () => { .send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); - 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. + it('matches download correctly when arrQueueId is sent as a string but stored as a number in queue cache (type mismatch regression)', async () => { + // Regression test for issue #48 (v2): arrQueueId from the SPA DOM dataset is always + // a string, but the queue record id from the Radarr/Sonarr API cache 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 } - ]); + // Seed Radarr queue with a numeric id (as Radarr API returns it) + cache.set('poll:radarr-queue', { records: [{ + id: 9050001, + title: 'Project.Hail.Mary.2026.2160p', + movieId: 77, + trackedDownloadState: 'downloading', + trackedDownloadStatus: 'ok', + _instanceUrl: RADARR_BASE, + _instanceKey: 'rk' + }] }, CACHE_TTL); nock(RADARR_BASE) .delete('/api/v3/queue/9050001') @@ -1002,7 +988,6 @@ describe('POST /api/dashboard/blocklist-search', () => { .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(); }); });