Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e726fbe33f | |||
| 6f2901b08c | |||
| 4107bdf611 | |||
| a4af16064b | |||
| 52806d00dc | |||
| d6907f42d3 | |||
| aec04474be | |||
| dcb77dd27f | |||
| f5315e5ceb | |||
| 13f3d767c5 | |||
| 6c3ffb9b77 | |||
| a37874c553 | |||
| 5933e09652 | |||
| 7226404221 | |||
| 1ee2a8044b | |||
| 86277e2059 |
+30
-1
@@ -4,11 +4,40 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Testing Scope Expansion (Issue #60)** — Significantly expanded unit and integration test coverage targeting the background polling engine, rate limiting, and frontend rendering logic to secure core behaviors.
|
||||||
|
- **Background Poller Tests (`poller.test.js`)**: Implemented robust unit tests verifying concurrency guards, subscriber registries (`onPollComplete`/`offPollComplete`), graceful error recovery, webhook bypasses, and global fallbacks using a hybrid testing approach with Vitest fake timers.
|
||||||
|
- **Isolated Rate Limiting (`rateLimiter.test.js`)**: Designed a dedicated integration test that unsets `SKIP_RATE_LIMIT` and asserts `429 Too Many Requests` responses under rapid login requests while maintaining test suite stability.
|
||||||
|
- **Active Downloads Decoration (`ombiDecoration.test.js`)**: Covered deep-link generation (`arrLink`) for active download matching under admin and non-admin states.
|
||||||
|
- **Frontend UI Rendering (`downloads.test.js`)**: Expanded coverage for the downloads rendering engine, specifically targeting conditional administrative icon construction (`createServiceIcons()`) and client logo loading fallbacks (`createClientLogo()`).
|
||||||
|
- **SSE Connection Lifecycle & Payload Contracts (`dashboard.test.js`)**: Added stream tests checking Server-Sent Event heartbeat emission, payload schema schemas, and client close event listeners (`req.on('close')`) to verify callback cleanup and prevent connection-based memory leaks.
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads.
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||||
|
|
||||||
|
## [1.7.27] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
|
||||||
|
|
||||||
## [1.7.26] - 2026-05-27
|
## [1.7.26] - 2026-05-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Missing Ombi & *Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and *Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
- **Missing Ombi & \*Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and \*Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||||
|
|
||||||
## [1.7.25] - 2026-05-27
|
## [1.7.25] - 2026-05-27
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function createRequestCard(request) {
|
|||||||
const actions = document.createElement('span');
|
const actions = document.createElement('span');
|
||||||
actions.className = 'service-icons-container';
|
actions.className = 'service-icons-container';
|
||||||
|
|
||||||
const id = request.theTvDbId || request.theMovieDbId || request.theTvdbId || request.theTmdbId || request.TvDbId || request.TheTvDbId || request.imdbId || request.ImdbId;
|
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||||
if (state.ombiBaseUrl && id) {
|
if (state.ombiBaseUrl && id) {
|
||||||
const ombiLink = document.createElement('a');
|
const ombiLink = document.createElement('a');
|
||||||
ombiLink.className = 'ombi-link';
|
ombiLink.className = 'ombi-link';
|
||||||
|
|||||||
+17
-5
@@ -1,7 +1,16 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
// Load env variables from root directory to match backend TLS configuration
|
||||||
|
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||||
|
|
||||||
|
const port = env.PORT || 3001;
|
||||||
|
const tlsEnabled = (env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
const target = `${tlsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||||
|
|
||||||
|
return {
|
||||||
build: {
|
build: {
|
||||||
outDir: '../public',
|
outDir: '../public',
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
@@ -18,11 +27,14 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
host: true, // Listen on all network interfaces
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: target,
|
||||||
changeOrigin: true
|
changeOrigin: true,
|
||||||
|
secure: false // Allow self-signed certificate in development
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
|
});
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.26",
|
"version": "1.7.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.26",
|
"version": "1.7.30",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+4
-2
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.26",
|
"version": "1.7.30",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev:server": "nodemon server/index.js",
|
||||||
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
"dev": "concurrently -n 'server,client' -c 'blue,green' 'npm run dev:server' 'npm run dev:client'",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"install:all": "npm install",
|
"install:all": "npm install",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
+40
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
|||||||
const swaggerJsdoc = require('swagger-jsdoc');
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
@@ -132,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.25"
|
* example: "1.7.30"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
@@ -232,6 +233,44 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/status', statusRoutes);
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files — served before API routes
|
||||||
|
// index.html is served manually so we can inject the CSP nonce
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||||
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
|
// Serve all static assets (js, css, images, icons) except index.html.
|
||||||
|
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||||
|
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||||
|
app.use(express.static(PUBLIC_DIR, {
|
||||||
|
index: false,
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serve index.html with CSP nonce injected into <script> tags
|
||||||
|
function serveIndex(req, res) {
|
||||||
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||||
|
if (err) return res.status(500).send('Internal Server Error');
|
||||||
|
const nonce = res.locals.cspNonce;
|
||||||
|
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||||
|
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||||
|
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||||
|
// the old nonce which no longer matches the per-request CSP header).
|
||||||
|
const patched = html
|
||||||
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(patched);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
|
app.get('*', serveIndex);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
+2
-287
@@ -82,20 +82,9 @@ console.error = function(...args) {
|
|||||||
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
|
||||||
const radarrRoutes = require('./routes/radarr');
|
|
||||||
const embyRoutes = require('./routes/emby');
|
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
|
||||||
const statusRoutes = require('./routes/status');
|
|
||||||
const historyRoutes = require('./routes/history');
|
|
||||||
const authRoutes = require('./routes/auth');
|
|
||||||
const webhookRoutes = require('./routes/webhook');
|
|
||||||
const ombiRoutes = require('./routes/ombi');
|
|
||||||
const debugRoutes = require('./routes/debug');
|
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
|
const { createApp } = require('./app');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Startup environment validation
|
// Startup environment validation
|
||||||
@@ -117,284 +106,10 @@ if (process.env.EMBY_URL) {
|
|||||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = createApp();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Load OpenAPI spec from YAML
|
|
||||||
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
|
||||||
|
|
||||||
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
|
||||||
const swaggerOptions = {
|
|
||||||
definition: {
|
|
||||||
...openapiSpec,
|
|
||||||
openapi: '3.1.0'
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
path.join(__dirname, 'routes/*.js'),
|
|
||||||
path.join(__dirname, 'index.js')
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|
||||||
|
|
||||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
|
||||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
|
||||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
|
||||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
|
||||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
if (process.env.TRUST_PROXY) {
|
|
||||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
|
||||||
? parseInt(process.env.TRUST_PROXY, 10)
|
|
||||||
: process.env.TRUST_PROXY;
|
|
||||||
app.set('trust proxy', trustValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helmet v7 — security response headers
|
|
||||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
|
||||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
// Generate a fresh nonce for every request
|
|
||||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
||||||
fontSrc: ["'self'", 'data:'],
|
|
||||||
connectSrc: ["'self'"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
baseUri: ["'self'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
formAction: ["'self'"],
|
|
||||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hsts: {
|
|
||||||
maxAge: 31536000, // 1 year
|
|
||||||
includeSubDomains: true,
|
|
||||||
preload: true
|
|
||||||
},
|
|
||||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
||||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.setHeader(
|
|
||||||
'Permissions-Policy',
|
|
||||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
|
||||||
);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// General API rate limiter — applies to all /api/* routes
|
|
||||||
// More specific limiters (e.g. login) apply on top of this.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
|
||||||
message: { error: 'Too many requests, please try again later' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Body parsing & cookies
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use(cookieParser(cookieSecret || undefined));
|
|
||||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Health / readiness endpoints (no auth, no rate-limit)
|
|
||||||
// Used by Docker HEALTHCHECK and orchestrators.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /health:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Health check
|
|
||||||
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is healthy
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ok"
|
|
||||||
* uptime:
|
|
||||||
* type: number
|
|
||||||
* description: Server uptime in seconds
|
|
||||||
* example: 3600.5
|
|
||||||
* version:
|
|
||||||
* type: string
|
|
||||||
* description: sofarr version
|
|
||||||
* example: "1.7.25"
|
|
||||||
*/
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /ready:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Readiness check
|
|
||||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ready"
|
|
||||||
* '503':
|
|
||||||
* description: Server not ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "not ready"
|
|
||||||
* reason:
|
|
||||||
* type: string
|
|
||||||
* example: "EMBY_URL not configured"
|
|
||||||
*/
|
|
||||||
app.get('/ready', (req, res) => {
|
|
||||||
// Confirm critical config is present
|
|
||||||
const ready = !!(process.env.EMBY_URL);
|
|
||||||
if (ready) {
|
|
||||||
res.json({ status: 'ready' });
|
|
||||||
} else {
|
|
||||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Swagger UI - publicly accessible API documentation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
|
||||||
customSiteTitle: 'sofarr API Documentation',
|
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
|
||||||
customJs: [
|
|
||||||
'/swagger-auth-banner.js'
|
|
||||||
],
|
|
||||||
swaggerOptions: {
|
|
||||||
url: '/api/swagger.json'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
|
||||||
app.get('/api/swagger.json', (req, res) => {
|
|
||||||
// Clone the spec to avoid modifying the original
|
|
||||||
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
|
||||||
|
|
||||||
// Replace the server URL with the current request's origin
|
|
||||||
if (specCopy.servers && specCopy.servers.length > 0) {
|
|
||||||
const protocol = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
specCopy.servers[0].url = `${protocol}://${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(specCopy);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Static files — served before API routes
|
|
||||||
// index.html is served manually so we can inject the CSP nonce
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
|
||||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
|
||||||
|
|
||||||
// Serve all static assets (js, css, images, icons) except index.html.
|
|
||||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
|
||||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
|
||||||
app.use(express.static(PUBLIC_DIR, {
|
|
||||||
index: false,
|
|
||||||
setHeaders(res, filePath) {
|
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve index.html with CSP nonce injected into <script> tags
|
|
||||||
function serveIndex(req, res) {
|
|
||||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
|
||||||
if (err) return res.status(500).send('Internal Server Error');
|
|
||||||
const nonce = res.locals.cspNonce;
|
|
||||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
|
||||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
|
||||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
|
||||||
// the old nonce which no longer matches the per-request CSP header).
|
|
||||||
const patched = html
|
|
||||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
||||||
res.send(patched);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
|
||||||
// CSRF protection applies to all state-changing /api/* requests except
|
|
||||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api', apiLimiter);
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/webhook', webhookRoutes);
|
|
||||||
app.use('/api/debug', debugRoutes);
|
|
||||||
|
|
||||||
// All routes below this point require CSRF validation on mutating methods
|
|
||||||
app.use('/api', verifyCsrf);
|
|
||||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
|
||||||
app.use('/api/sonarr', sonarrRoutes);
|
|
||||||
app.use('/api/radarr', radarrRoutes);
|
|
||||||
app.use('/api/emby', embyRoutes);
|
|
||||||
app.use('/api/ombi', ombiRoutes);
|
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
|
||||||
app.use('/api/status', statusRoutes);
|
|
||||||
app.use('/api/history', historyRoutes);
|
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
|
||||||
app.get('*', serveIndex);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global error handler — never leak stack traces to clients
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error('[Server] Unhandled error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TLS / HTTPS support
|
// TLS / HTTPS support
|
||||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
|||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.7.26
|
version: 1.7.30
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
|
|||||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { canBlocklist } = require('../services/DownloadAssembler');
|
const { canBlocklist } = require('../services/DownloadAssembler');
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: user.name,
|
user: user.name,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -513,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
||||||
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
||||||
id: c.getInstanceId(),
|
id: c.getInstanceId(),
|
||||||
|
|||||||
@@ -115,10 +115,10 @@ async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
|||||||
requests.forEach(req => {
|
requests.forEach(req => {
|
||||||
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
||||||
// Fallback to checking for TV specific IDs.
|
// Fallback to checking for TV specific IDs.
|
||||||
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.theTvDbId;
|
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
|
||||||
|
|
||||||
if (isTv) {
|
if (isTv) {
|
||||||
const tvdbId = req.theTvDbId || req.theMovieDbId || req.theTvdbId || req.theTmdbId || req.TvDbId || req.TheTvDbId;
|
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
|
||||||
if (!tvdbId) return;
|
if (!tvdbId) return;
|
||||||
|
|
||||||
for (const instData of sonarrData) {
|
for (const instData of sonarrData) {
|
||||||
@@ -145,8 +145,95 @@ async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decorateDownloadsWithArrLinks(downloads, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(downloads)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
downloads.forEach(dl => {
|
||||||
|
// Determine if it's TV (series) or Movie
|
||||||
|
const isTv = dl.type === 'series' || dl.arrType === 'sonarr';
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
// Look for a match in Sonarr instances
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => {
|
||||||
|
if (!s) return false;
|
||||||
|
// Match by database series ID if the instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to seriesName matching
|
||||||
|
if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dl.type === 'movie' || dl.arrType === 'radarr') {
|
||||||
|
// Look for a match in Radarr instances
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => {
|
||||||
|
if (!m) return false;
|
||||||
|
// Match by database movie ID if instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to movieName matching
|
||||||
|
if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extractRequestedUser,
|
extractRequestedUser,
|
||||||
filterRequestsByUser,
|
filterRequestsByUser,
|
||||||
decorateRequestsWithArrLinks
|
decorateRequestsWithArrLinks,
|
||||||
|
decorateDownloadsWithArrLinks
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,3 +98,139 @@ describe('renderTagBadges', () => {
|
|||||||
expect(result.childNodes.length).toBe(0);
|
expect(result.childNodes.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { createDownloadCard } from '../../../client/src/ui/downloads.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
describe('createDownloadCard rendering details', () => {
|
||||||
|
let originalState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalState = { ...state };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset global state
|
||||||
|
Object.assign(state, originalState);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createClientLogo and fallbacks', () => {
|
||||||
|
it('renders client logo img tag when client is configured', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'qbittorrent',
|
||||||
|
instanceName: 'Qbit Main'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
|
||||||
|
const img = wrapper.querySelector('img.download-client-logo');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.src).toContain('/images/clients/qbittorrent.svg');
|
||||||
|
expect(img.alt).toBe('Qbit Main icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to character avatar text on img load error', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'transmission'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
const img = wrapper.querySelector('img');
|
||||||
|
|
||||||
|
// Trigger the onerror event programmatically to simulate missing/broken SVG
|
||||||
|
img.onerror();
|
||||||
|
|
||||||
|
expect(wrapper.classList.contains('fallback')).toBe(true);
|
||||||
|
expect(wrapper.textContent).toBe('T');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createServiceIcons deep-linking', () => {
|
||||||
|
it('renders Ombi icon link for all users when ombiLink exists', () => {
|
||||||
|
state.isAdmin = false; // Non-admin should still see Ombi icon
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
ombiLink: 'https://ombi.test/request/42',
|
||||||
|
ombiTooltip: 'View on Ombi'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const ombiLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(ombiLinkEl).toBeTruthy();
|
||||||
|
expect(ombiLinkEl.href).toBe('https://ombi.test/request/42');
|
||||||
|
|
||||||
|
const img = ombiLinkEl.querySelector('img.service-icon.ombi');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('View on Ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr icon link for administrator when arrType is sonarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Sonarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://sonarr.test/series/the-mandalorian');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.sonarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Radarr icon link for administrator when arrType is radarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Radarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Blade Runner 2049',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrType: 'radarr',
|
||||||
|
arrLink: 'https://radarr.test/movie/blade-runner-2049'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-movie a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://radarr.test/movie/blade-runner-2049');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.radarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Sonarr/Radarr links if the user is a non-admin', () => {
|
||||||
|
state.isAdmin = false; // Non-admin
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeNull(); // Admin gate successfully hides Sonarr icon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -563,6 +563,47 @@ describe('GET /api/dashboard/user-downloads', () => {
|
|||||||
expect(dl.canBlocklist).toBe(true);
|
expect(dl.canBlocklist).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
|
||||||
|
it('decorates active series downloads with Sonarr links for administrator', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
|
||||||
|
// Seed cache: queue record exists and matches SABnzbd slot
|
||||||
|
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
|
||||||
|
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr /api/v3/movie response
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const downloads = res.body.downloads;
|
||||||
|
const dl = downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
|
||||||
|
expect(dl.arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1093,5 +1134,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () =>
|
|||||||
expect(data.ombiRequests.movie).toHaveLength(2);
|
expect(data.ombiRequests.movie).toHaveLength(2);
|
||||||
expect(data.ombiRequests.tv).toHaveLength(2);
|
expect(data.ombiRequests.tv).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verifies SSE payload structure contract against the frontend schema', async () => {
|
||||||
|
const { cookies } = await loginAs(appInstance);
|
||||||
|
const res = await request(appInstance)
|
||||||
|
.get('/api/dashboard/stream')
|
||||||
|
.query({ testClose: 'true' })
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = res.text;
|
||||||
|
expect(text).toContain('data:');
|
||||||
|
|
||||||
|
const dataStr = text.substring(text.indexOf('{'));
|
||||||
|
const data = JSON.parse(dataStr.trim());
|
||||||
|
|
||||||
|
// Payload Contract Validation
|
||||||
|
expect(data).toHaveProperty('user');
|
||||||
|
expect(data).toHaveProperty('isAdmin');
|
||||||
|
expect(data).toHaveProperty('downloads');
|
||||||
|
expect(data).toHaveProperty('downloadClients');
|
||||||
|
expect(data).toHaveProperty('ombiRequests');
|
||||||
|
expect(data).toHaveProperty('ombiBaseUrl');
|
||||||
|
|
||||||
|
expect(Array.isArray(data.downloads)).toBe(true);
|
||||||
|
expect(Array.isArray(data.downloadClients)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends heartbeat comment over active stream and cleans up on close', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// 1. Get the route handler from the dashboard router stack
|
||||||
|
const dashboardRouter = require('../../server/routes/dashboard.js');
|
||||||
|
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
|
||||||
|
// Get the final handler (after requireAuth middleware)
|
||||||
|
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
|
||||||
|
|
||||||
|
// 2. Setup mock req and res
|
||||||
|
const mockUser = { name: 'Alice', isAdmin: false };
|
||||||
|
const reqOnCallbacks = {};
|
||||||
|
const mockReq = {
|
||||||
|
user: mockUser,
|
||||||
|
query: { showAll: 'false', testClose: 'false' },
|
||||||
|
on: vi.fn((event, cb) => {
|
||||||
|
reqOnCallbacks[event] = cb;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const resWrites = [];
|
||||||
|
const mockRes = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
flushHeaders: vi.fn(),
|
||||||
|
write: vi.fn((data) => {
|
||||||
|
resWrites.push(data);
|
||||||
|
}),
|
||||||
|
end: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Call the handler
|
||||||
|
await streamHandler(mockReq, mockRes);
|
||||||
|
|
||||||
|
// Initial payload should be written
|
||||||
|
expect(resWrites.length).toBeGreaterThan(0);
|
||||||
|
expect(resWrites[0]).toContain('data:');
|
||||||
|
|
||||||
|
// 4. Advance time by 25s to trigger the heartbeat setInterval
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
|
||||||
|
// Check that heartbeat was written
|
||||||
|
expect(resWrites).toContain(': heartbeat\n\n');
|
||||||
|
|
||||||
|
// 5. Simulate client disconnect by triggering the 'close' event callback
|
||||||
|
expect(reqOnCallbacks['close']).toBeDefined();
|
||||||
|
reqOnCallbacks['close']();
|
||||||
|
|
||||||
|
// Check that advancing time again does NOT write another heartbeat
|
||||||
|
const beforeLength = resWrites.length;
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
expect(resWrites.length).toBe(beforeLength); // No new writes!
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
|
|||||||
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
||||||
|
// 1. Setup mock instance config
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
||||||
|
const tvRequestsWithTvDbId = [
|
||||||
|
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
||||||
|
|
||||||
|
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 4. Assert decoration succeeded
|
||||||
|
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
||||||
|
expect(supermanShow).toBeDefined();
|
||||||
|
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
||||||
|
expect(supermanShow.arrType).toBe('sonarr');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
it('handles case-insensitive username matching', async () => {
|
it('handles case-insensitive username matching', async () => {
|
||||||
const requestsWithMixedCase = [
|
const requestsWithMixedCase = [
|
||||||
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
|
||||||
|
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
||||||
|
|
||||||
|
const SONARR_BASE = 'https://sonarr-decor.test';
|
||||||
|
const RADARR_BASE = 'https://radarr-decor.test';
|
||||||
|
|
||||||
|
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
nock.cleanAll();
|
||||||
|
|
||||||
|
// Reset the singleton retrievers registry so we can inject our test instances
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// Configure test environment variables for retrievers
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a series download with Sonarr link matching on title', async () => {
|
||||||
|
// Mock Sonarr series query
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr movie query (empty)
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrSeriesId: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
|
||||||
|
expect(downloads[0].arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a movie download with Radarr link matching on content ID', async () => {
|
||||||
|
// Mock Sonarr series query (empty)
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
// Mock Radarr movie query with matching ID
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'Blade.Runner.2049.2017.1080p',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrInstanceUrl: RADARR_BASE,
|
||||||
|
arrContentId: 99
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
|
||||||
|
expect(downloads[0].arrType).toBe('radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips decoration entirely when isAdmin is false', async () => {
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// No nocks are set up, so any HTTP calls would throw or error
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, false);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty downloads array gracefully', async () => {
|
||||||
|
// No mock setups needed, should complete without throwing
|
||||||
|
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
|
||||||
|
// Mock Sonarr series query throwing connection error
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.replyWithError('connection refused');
|
||||||
|
|
||||||
|
// Mock Radarr movie query throwing timeout error
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.replyWithError('timeout');
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// No links decorated since the fetch failed
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
describe('Rate Limiting Integration Tests', () => {
|
||||||
|
let app;
|
||||||
|
let originalSkipRateLimit;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Save current rate limiting skip flag
|
||||||
|
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
|
||||||
|
// Explicitly delete it before loading the app so rate limiters are active
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
process.env.EMBY_URL = 'https://emby.test';
|
||||||
|
|
||||||
|
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
|
||||||
|
const appModule = await import('../../server/app.js');
|
||||||
|
const createApp = appModule.createApp;
|
||||||
|
|
||||||
|
// Create a new app instance with rate limiting enabled
|
||||||
|
app = createApp({ skipRateLimits: false });
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore rate limit skip flag
|
||||||
|
if (originalSkipRateLimit !== undefined) {
|
||||||
|
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
|
||||||
|
} else {
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
}
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
|
||||||
|
// Mock Emby server auth endpoint to return 401 (failed credentials).
|
||||||
|
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
|
||||||
|
// count toward the rate limit window of 10 requests.
|
||||||
|
nock('https://emby.test')
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(401, { error: 'Unauthorized' })
|
||||||
|
.persist();
|
||||||
|
|
||||||
|
// Fire 10 rapid failed login requests (the limit is 10)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 11th request must be rate limited and return 429
|
||||||
|
const limitRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(limitRes.status).toBe(429);
|
||||||
|
expect(limitRes.body.error).toContain('Too many login attempts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Set environment variables before requiring any modules
|
||||||
|
process.env.POLL_INTERVAL = '5000';
|
||||||
|
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
|
||||||
|
|
||||||
|
const cache = require('../../../server/utils/cache.js');
|
||||||
|
const downloadClients = require('../../../server/utils/downloadClients.js');
|
||||||
|
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
|
||||||
|
const config = require('../../../server/utils/config.js');
|
||||||
|
|
||||||
|
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
|
||||||
|
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
|
||||||
|
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
|
||||||
|
sabnzbd: [],
|
||||||
|
qbittorrent: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
|
||||||
|
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
|
||||||
|
movie: [],
|
||||||
|
tv: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
|
||||||
|
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
|
||||||
|
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
|
||||||
|
|
||||||
|
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
|
||||||
|
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
|
||||||
|
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
||||||
|
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: null
|
||||||
|
});
|
||||||
|
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Now require the poller
|
||||||
|
const poller = require('../../../server/utils/poller.js');
|
||||||
|
|
||||||
|
describe('Background Poller Utility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Re-apply standard resolved values
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
|
||||||
|
arrRegistryInitializeSpy.mockResolvedValue(true);
|
||||||
|
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
|
||||||
|
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getOmbiInstancesSpy.mockReturnValue([]);
|
||||||
|
|
||||||
|
cacheSetSpy.mockImplementation(() => {});
|
||||||
|
cacheGetSpy.mockReturnValue(null);
|
||||||
|
getWebhookMetricsSpy.mockReturnValue(null);
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
|
||||||
|
incrementPollsSkippedSpy.mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
poller.stopPoller();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Poller Core Logic', () => {
|
||||||
|
it('POLL_INTERVAL matches parsed environment variable', () => {
|
||||||
|
expect(poller.POLL_INTERVAL).toBe(5000);
|
||||||
|
expect(poller.POLLING_ENABLED).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
|
||||||
|
let callbackFired = false;
|
||||||
|
const callback = () => {
|
||||||
|
callbackFired = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
poller.onPollComplete(callback);
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(callbackFired).toBe(true);
|
||||||
|
|
||||||
|
// Clean up/Deregister callback
|
||||||
|
poller.offPollComplete(callback);
|
||||||
|
callbackFired = false;
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(callbackFired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
|
||||||
|
// Stub initializeClients to delay using a promise
|
||||||
|
let resolveInit;
|
||||||
|
const delayPromise = new Promise((resolve) => {
|
||||||
|
resolveInit = resolve;
|
||||||
|
});
|
||||||
|
initializeClientsSpy.mockImplementation(() => delayPromise);
|
||||||
|
|
||||||
|
// Start the first poll (which remains pending on initializeClients)
|
||||||
|
const firstPollPromise = poller.pollAllServices();
|
||||||
|
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Trigger second poll immediately while first is in progress
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
|
||||||
|
|
||||||
|
// Resolve the delay to let the first poll finish
|
||||||
|
resolveInit();
|
||||||
|
await firstPollPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the polling guard flag on error so future polls can run', async () => {
|
||||||
|
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
|
||||||
|
|
||||||
|
// Setup error spy
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
|
||||||
|
|
||||||
|
// Verify polling flag has been reset in the finally block by running a successful poll
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Webhook-Based Instance Bypassing', () => {
|
||||||
|
it('skips polling for an instance with recent active webhook events', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Verify that skips are incremented for both
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
|
||||||
|
|
||||||
|
// Verify that Sonarr/Radarr-specific API retrievers were not called
|
||||||
|
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
|
||||||
|
const staleTimestamp = Date.now() - 11 * 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Should bypass the skip and perform a full poll
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent metrics on individual level but stale globally
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global webhook is stale
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Stale global webhooks should trigger fallback, bypassing the individual skip
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hybrid Timer Behavior (Fake Timers)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules periodic polls in startPoller on standard interval', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
|
||||||
|
// Triggered immediately on start (flush microtasks)
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time by 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Advance by another 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears intervals cleanly when stopPoller is called', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user