Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f6aa5b967 | |||
| 5390bbf615 | |||
| fb68bddedb | |||
| 81d0aa82f2 | |||
| 7d7304637c | |||
| d87ad9f1c7 | |||
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 | |||
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e |
+2
-1
@@ -11,4 +11,5 @@ data/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.agents/
|
||||
.windsurf/
|
||||
.windsurf/
|
||||
scratch/
|
||||
+10
-9
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
|
||||
Both endpoints share identical processing logic:
|
||||
|
||||
```
|
||||
Sonarr/Radarr
|
||||
Sonarr/Radarr/Ombi
|
||||
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",
|
||||
"date": "2026-05-19T10:00:00.000Z", … }
|
||||
│
|
||||
@@ -404,6 +404,7 @@ Sonarr/Radarr
|
||||
│
|
||||
▼
|
||||
validateWebhookSecret() ──fail──► 401 Unauthorized
|
||||
(Checks header or query param)
|
||||
│ ok
|
||||
▼
|
||||
validatePayload() ──fail──► 400 Bad Request
|
||||
@@ -1002,19 +1003,19 @@ sofarr provides a togglable, real-time log capturing and streaming engine allowi
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Browser (SPA)
|
||||
subgraph Browser ["Browser (SPA)"]
|
||||
console["console.log/warn/error"] --> queue["logQueue (batched)"]
|
||||
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
|
||||
end
|
||||
|
||||
subgraph Node.js (Server)
|
||||
subgraph Server ["Node.js (Server)"]
|
||||
stdout["process.stdout.write"] --> capture["processStreamData()"]
|
||||
stderr["process.stderr.write"] --> capture
|
||||
capture --> |stripAnsi()| serverBuffer["logBuffer (rolling 1000 lines)"]
|
||||
capture --> |emit('server-log')| serverSse["GET /api/debug/server-logs (SSE)"]
|
||||
capture --> |stripAnsi| serverBuffer["logBuffer (rolling 1000 lines)"]
|
||||
capture --> |emit server-log| serverSse["GET /api/debug/server-logs (SSE)"]
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1224,7 +1225,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
||||
| Concern | Mechanism |
|
||||
|---------|-----------|
|
||||
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
||||
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
|
||||
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). Bypassed in testing/dev via `SKIP_RATE_LIMIT=1`. |
|
||||
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
||||
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
||||
|
||||
@@ -1232,7 +1233,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
||||
|
||||
| Concern | Mechanism |
|
||||
|---------|-----------|
|
||||
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
|
||||
| **Rate limiting** | General API limiter (300 req/15 min on `/api/*` prefix) exempts `/api/dashboard/cover-art` requests; Login limiter (10 attempts/15 min) employs `skipSuccessfulRequests: true` to count failed attempts only; Webhook limiter runs 60 req/1 min on `/api/webhook/*` endpoints; Root `/health` and `/ready` probes are entirely exempt. All limiters bypassable in testing via `SKIP_RATE_LIMIT=1` or `createApp({ skipRateLimits: true })`. |
|
||||
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
||||
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
||||
| **Body size** | `express.json` body limit: 64 KB. |
|
||||
|
||||
@@ -4,6 +4,81 @@ 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.20] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.19] - 2026-05-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
|
||||
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.18] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mobile overflow on Requests tab** — Request cards no longer extend off the right edge of the screen on mobile browsers. Removed `white-space: nowrap` from `.request-title` to allow text truncation with ellipsis, added `overflow-x: hidden` to `.requests-list` as a safety net, and added `@media (max-width: 768px)` rules to reduce padding and tighten gaps on mobile. Resolves Gitea Issue [#49](https://git.i3omb.com/Gandalf/sofarr/issues/49).
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
- **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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.13] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
|
||||
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
|
||||
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.12] - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
+8
-7
@@ -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 |
|
||||
| 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 |
|
||||
| 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 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/*` |
|
||||
@@ -162,12 +162,13 @@ server {
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Endpoint | Limit |
|
||||
|----------|-------|
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
||||
| `GET /api/swagger` | No rate limit (public documentation) |
|
||||
| Endpoint | Limit | Details & Exemptions |
|
||||
|----------|-------|----------------------|
|
||||
| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. |
|
||||
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
|
||||
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.20",
|
||||
"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": {
|
||||
|
||||
+19
-1
@@ -1888,6 +1888,23 @@ body {
|
||||
|
||||
/* ===== Mobile ===== */
|
||||
@media (max-width: 768px) {
|
||||
.main-tabs {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.requests-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -2234,6 +2251,7 @@ body {
|
||||
.requests-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
@@ -2245,6 +2263,7 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-card:hover {
|
||||
@@ -2273,7 +2292,6 @@ body {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
+6
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const YAML = require('yamljs');
|
||||
const path = require('path');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
@@ -128,13 +129,17 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
* type: number
|
||||
* description: Server uptime in seconds
|
||||
* example: 3600.5
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.20"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: curl http://localhost:3001/health
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,20 @@ class OmbiClient {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users from Ombi
|
||||
* @returns {Promise<Array>} Array of user objects
|
||||
*/
|
||||
async getUsers() {
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] Get users error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OmbiClient;
|
||||
|
||||
@@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever {
|
||||
this.cache = {
|
||||
movieRequests: [],
|
||||
tvRequests: [],
|
||||
users: [],
|
||||
movieMap: new Map(), // tmdbId -> request
|
||||
tvMap: new Map(), // tvdbId -> request
|
||||
userMap: new Map(), // id -> user
|
||||
lastFetch: 0,
|
||||
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||
};
|
||||
@@ -98,20 +100,32 @@ class OmbiRetriever extends ArrRetriever {
|
||||
try {
|
||||
logToFile('[OmbiRetriever] Refreshing cache');
|
||||
|
||||
// Fetch requests in parallel
|
||||
const [movieRequests, tvRequests] = await Promise.all([
|
||||
// Fetch requests and users in parallel
|
||||
const [movieRequests, tvRequests, users] = await Promise.all([
|
||||
this.client.getMovieRequests(),
|
||||
this.client.getTvRequests()
|
||||
this.client.getTvRequests(),
|
||||
this.client.getUsers()
|
||||
]);
|
||||
|
||||
// Update cache
|
||||
this.cache.movieRequests = movieRequests;
|
||||
this.cache.tvRequests = tvRequests;
|
||||
this.cache.users = users;
|
||||
this.cache.lastFetch = Date.now();
|
||||
|
||||
// Build lookup maps
|
||||
this.cache.movieMap.clear();
|
||||
this.cache.tvMap.clear();
|
||||
this.cache.userMap.clear();
|
||||
|
||||
// Build user map (id -> user)
|
||||
if (Array.isArray(users)) {
|
||||
users.forEach(user => {
|
||||
if (user && user.id) {
|
||||
this.cache.userMap.set(user.id, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build movie map (tmdbId -> request)
|
||||
movieRequests.forEach(request => {
|
||||
@@ -133,13 +147,59 @@ class OmbiRetriever extends ArrRetriever {
|
||||
}
|
||||
});
|
||||
|
||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
|
||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`);
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||
// Don't throw error, continue with stale cache if available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates requestedUser on a single request using the userMap cache
|
||||
* @param {Object} req - The request object
|
||||
* @returns {Object} Hydrated request object
|
||||
* @private
|
||||
*/
|
||||
_hydrateRequest(req) {
|
||||
if (!req) return req;
|
||||
|
||||
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||
|
||||
let requestedUser = req.requestedUser || req.RequestedUser;
|
||||
|
||||
// If requestedUser is not an object or is empty/null, populate it
|
||||
if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) {
|
||||
const hydratedUser = {
|
||||
id: cachedUser.id,
|
||||
userName: cachedUser.userName,
|
||||
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||
};
|
||||
|
||||
return {
|
||||
...req,
|
||||
requestedUser: hydratedUser,
|
||||
RequestedUser: hydratedUser
|
||||
};
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates requestedUser on a list of requests using the userMap cache
|
||||
* @param {Array} requests - Array of request objects
|
||||
* @returns {Array} Array of hydrated request objects
|
||||
* @private
|
||||
*/
|
||||
_hydrateRequests(requests) {
|
||||
if (!Array.isArray(requests)) return [];
|
||||
return requests.map(req => this._hydrateRequest(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all movie requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
@@ -147,7 +207,7 @@ class OmbiRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getMovieRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.movieRequests;
|
||||
return this._hydrateRequests(this.cache.movieRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +217,7 @@ class OmbiRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getTvRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.tvRequests;
|
||||
return this._hydrateRequests(this.cache.tvRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,12 +231,12 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
// Try TMDB ID first
|
||||
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
||||
return this.cache.movieMap.get(tmdbId);
|
||||
return this._hydrateRequest(this.cache.movieMap.get(tmdbId));
|
||||
}
|
||||
|
||||
// Try IMDB ID as fallback
|
||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||
return this.cache.movieMap.get(imdbId);
|
||||
return this._hydrateRequest(this.cache.movieMap.get(imdbId));
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -193,12 +253,12 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
// Try TVDB ID first
|
||||
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
||||
return this.cache.tvMap.get(tvdbId);
|
||||
return this._hydrateRequest(this.cache.tvMap.get(tvdbId));
|
||||
}
|
||||
|
||||
// Try TMDB ID as fallback
|
||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||
return this.cache.tvMap.get(tmdbId);
|
||||
return this._hydrateRequest(this.cache.tvMap.get(tmdbId));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.6.0"
|
||||
* example: "1.7.20"
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
|
||||
+53
-7
@@ -12,13 +12,17 @@ info:
|
||||
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
||||
|
||||
## Rate Limiting
|
||||
- General API: 300 requests per 15 minutes per IP
|
||||
- Login: 10 failed attempts per 15 minutes per IP
|
||||
- Webhooks: 60 requests per minute per IP
|
||||
To protect the system from resource exhaustion, rate limiters are enforced at different levels:
|
||||
- **General API Limiter**: Enforces a limit of **300 requests per 15 minutes** per IP across all `/api/*` endpoints.
|
||||
- *Exemption:* Requests starting with `/api/dashboard/cover-art` are completely exempted from this limit to avoid normal dashboard image browsing triggering blocks.
|
||||
- **Login Rate Limiter**: Enforces a strict limit of **10 attempts per 15 minutes** per IP on `POST /api/auth/login`.
|
||||
- *Exemption:* This limiter only tracks and counts *failed* login attempts (`skipSuccessfulRequests: true`). Successful logins do not count towards the lockout threshold.
|
||||
- **Webhook Limiter**: Enforces a limit of **60 requests per minute** per IP on stateful webhook receiver endpoints (`/api/webhook/*`).
|
||||
- **Health and Readiness Probes**: The public `/health` and `/ready` endpoints are mounted at the root directory level rather than under `/api/*` and are completely exempt from both rate limiting and authentication controls.
|
||||
|
||||
## SSE Streaming
|
||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||
version: 1.6.0
|
||||
version: 1.7.20
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
@@ -172,6 +176,27 @@ components:
|
||||
nullable: true
|
||||
description: Tooltip text for Ombi icon ("Request" or "Search")
|
||||
example: "Request"
|
||||
arrLink:
|
||||
type: string
|
||||
nullable: true
|
||||
format: uri
|
||||
description: Sonarr/Radarr show/movie web UI link (admin-only)
|
||||
example: "http://sonarr:8989/series/show-slug"
|
||||
downloadPath:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Save path in download client (admin-only)
|
||||
example: "/downloads/series/show-slug"
|
||||
targetPath:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Target path in library (admin-only)
|
||||
example: "/tv/show-slug"
|
||||
arrInstanceKey:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Sonarr/Radarr instance API key (admin-only)
|
||||
example: "api-key-here"
|
||||
|
||||
DashboardPayload:
|
||||
type: object
|
||||
@@ -791,8 +816,15 @@ paths:
|
||||
post:
|
||||
tags: [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: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -828,8 +860,15 @@ paths:
|
||||
post:
|
||||
tags: [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: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -865,8 +904,15 @@ paths:
|
||||
post:
|
||||
tags: [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: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
||||
+52
-10
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
|
||||
function buildMetadataMaps(snapshot) {
|
||||
const seriesMap = new Map();
|
||||
for (const r of snapshot.sonarrQueue.data.records) {
|
||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||
if (r.series && r.seriesId) {
|
||||
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||
r.series._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
}
|
||||
for (const r of snapshot.sonarrHistory.data.records) {
|
||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||
if (r.series && r.seriesId) {
|
||||
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||
r.series._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
const existing = seriesMap.get(r.seriesId);
|
||||
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
|
||||
seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
}
|
||||
}
|
||||
const moviesMap = new Map();
|
||||
for (const r of snapshot.radarrQueue.data.records) {
|
||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||
if (r.movie && r.movieId) {
|
||||
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||
r.movie._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
}
|
||||
for (const r of snapshot.radarrHistory.data.records) {
|
||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||
if (r.movie && r.movieId) {
|
||||
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||
r.movie._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
const existing = moviesMap.get(r.movieId);
|
||||
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
|
||||
moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
||||
@@ -686,17 +712,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
|
||||
const allDownloads = await downloadClientRegistry.getAllDownloads();
|
||||
const download = allDownloads.find(d => d.arrQueueId === 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' });
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||
|
||||
// Call Ombi API to register webhook
|
||||
const axios = require('axios');
|
||||
|
||||
+31
-11
@@ -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
|
||||
* @returns {boolean} True if secret is valid, false otherwise
|
||||
*/
|
||||
function validateWebhookSecret(req) {
|
||||
const expectedSecret = getWebhookSecret();
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
|
||||
|
||||
if (!expectedSecret) {
|
||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||
@@ -158,7 +158,7 @@ function validateWebhookSecret(req) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -309,13 +309,13 @@ function validatePayload(body) {
|
||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||
* 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).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **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)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
@@ -342,6 +342,13 @@ function validatePayload(body) {
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -456,13 +463,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||
* 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).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **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)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - 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}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -603,13 +617,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
||||
* 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).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **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)
|
||||
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
||||
* - 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
|
||||
*
|
||||
* **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
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Application Token: OMBI_API_KEY
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
|
||||
@@ -215,6 +215,9 @@ async function matchSabSlots(slots, context) {
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
@@ -269,6 +272,9 @@ async function matchSabSlots(slots, context) {
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
@@ -459,6 +465,9 @@ async function matchTorrents(torrents, context) {
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
@@ -505,6 +514,9 @@ async function matchTorrents(torrents, context) {
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
getRadarrInstances,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -349,6 +349,7 @@ describe('GET /api/dashboard/user-downloads', () => {
|
||||
expect(dl.arrQueueId).toBe(1002);
|
||||
expect(dl.arrType).toBe('sonarr');
|
||||
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
|
||||
expect(dl.downloadPath).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -749,12 +750,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 +766,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 +781,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 +813,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 +843,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 +861,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 +885,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 +905,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 +929,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,7 +953,42 @@ 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 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);
|
||||
|
||||
// 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')
|
||||
.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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -856,7 +856,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.post('/api/v1/Settings/notifications/webhook', {
|
||||
id: 42,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
@@ -870,7 +870,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.expect(200);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -882,7 +882,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.post('/api/v1/Settings/notifications/webhook', {
|
||||
id: 0,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
|
||||
@@ -196,6 +196,18 @@ describe('Swagger Coverage', () => {
|
||||
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Debug logging endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/debug/status']).toBeDefined();
|
||||
expect(paths['/api/debug/status'].get).toBeDefined();
|
||||
expect(paths['/api/debug/server-logs']).toBeDefined();
|
||||
expect(paths['/api/debug/server-logs'].get).toBeDefined();
|
||||
expect(paths['/api/debug/client-logs']).toBeDefined();
|
||||
expect(paths['/api/debug/client-logs'].get).toBeDefined();
|
||||
expect(paths['/api/debug/client-logs'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 200 for Swagger UI endpoint', async () => {
|
||||
const response = await request(app).get('/api/swagger').redirects(1);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -156,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
|
||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||
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', () => {
|
||||
@@ -171,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
|
||||
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const app = makeApp();
|
||||
const res = await postOmbi(app, { requestId: 1 });
|
||||
|
||||
@@ -336,4 +336,47 @@ describe('OmbiClient', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsers', () => {
|
||||
it('should return user array for successful request', async () => {
|
||||
const mockUsers = [
|
||||
{ id: '1', userName: 'Gordon' },
|
||||
{ id: '2', userName: 'Alice' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockUsers);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getUsers();
|
||||
|
||||
expect(result).toEqual(mockUsers);
|
||||
});
|
||||
|
||||
it('should return empty array on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getUsers();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on network error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.replyWithError('Network error');
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getUsers();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -766,4 +766,70 @@ describe('OmbiRetriever', () => {
|
||||
expect(stats.age).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydration logic', () => {
|
||||
it('should hydrate requestedUser when missing but requestedUserId is present', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', requestedUserId: 'gordon-id', requestedUser: null }
|
||||
];
|
||||
const mockTvShows = [];
|
||||
const mockUsers = [
|
||||
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.reply(200, mockUsers);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getMovieRequests();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].requestedUser).toBeDefined();
|
||||
expect(result[0].requestedUser.userName).toBe('Gordon');
|
||||
expect(result[0].requestedUser.alias).toBe('G-Man');
|
||||
});
|
||||
|
||||
it('should not overwrite non-empty requestedUser object', async () => {
|
||||
const mockMovies = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Movie 1',
|
||||
requestedUserId: 'gordon-id',
|
||||
requestedUser: { userName: 'ExistingGordon', alias: 'ExistingG' }
|
||||
}
|
||||
];
|
||||
const mockTvShows = [];
|
||||
const mockUsers = [
|
||||
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.reply(200, mockUsers);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getMovieRequests();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].requestedUser.userName).toBe('ExistingGordon');
|
||||
expect(result[0].requestedUser.alias).toBe('ExistingG');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user