Files
sofarr/tests/integration/dashboard.test.js
T
gronod 7690d959b3
CI / Security audit (push) Successful in 1m52s
Docs Check / Markdown lint (push) Successful in 1m37s
Build and Push Docker Image / build (push) Successful in 2m2s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
CI / Swagger Validation & Coverage (push) Successful in 3m17s
Docs Check / Mermaid diagram parse check (push) Successful in 3m31s
CI / Tests & coverage (push) Successful in 4m5s
fix: blocklist-search lookup against queue cache instead of downloadClientRegistry
Fixes the root cause of the regression from v1.7.16. The v1.7.16 fix
correctly cast arrQueueId to String, but the lookup was performed
against downloadClientRegistry.getAllDownloads() which returns raw
download client data (qBittorrent, SABnzbd, etc.) that never has
arrQueueId populated.

The fix now looks up the queue record directly from the Sonarr/Radarr
queue cache 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
Closes #48
2026-05-24 22:48:17 +01:00

1097 lines
43 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for server/routes/dashboard.js
*
* Strategy:
* - createApp({ skipRateLimits: true }) for a real Express instance
* - nock intercepts Emby auth so we can obtain a valid session cookie
* - cache is seeded directly (same technique as history.test.js) so the
* route's cache.get() calls return controlled fixture data without any
* real outbound HTTP to SABnzbd / Sonarr / Radarr / qBittorrent
* - nock is used for outbound axios calls made by the routes themselves
* (cover-art proxy, blocklist-search, status webhook-check)
*
* Covers:
* GET /api/dashboard/user-downloads — auth guard, SAB+Sonarr, SAB+Radarr,
* qBittorrent, showAll (admin), empty cache, on-demand poll trigger,
* paused queue speed, error propagation
* GET /api/dashboard/status — admin-only guard, shape check
* GET /api/dashboard/webhook-metrics — any authenticated user
* GET /api/dashboard/cover-art — missing url, non-http scheme, proxy, non-image
* POST /api/dashboard/blocklist-search — admin guard, validation, sonarr+radarr paths
*/
import request from 'supertest';
import nock from 'nock';
import { createRequire } from 'module';
import { createApp } from '../../server/app.js';
const require = createRequire(import.meta.url);
const cache = require('../../server/utils/cache.js');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const EMBY_BASE = 'https://emby.test';
const SONARR_BASE = 'https://sonarr.test';
const RADARR_BASE = 'https://radarr.test';
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire in a test run
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
const EMBY_ADMIN_AUTH = { AccessToken: 'tok-admin', User: { Id: 'uid2', Name: 'admin' } };
const EMBY_ADMIN_USER = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
// Tag id 1 → 'alice', id 2 → 'admin'
const SONARR_TAGS = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
const RADARR_TAGS = [{ id: 10, label: 'alice' }, { id: 11, label: 'admin' }];
const SERIES = {
id: 42,
title: 'My Show',
titleSlug: 'my-show',
tags: [1],
path: '/tv/my-show',
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/poster.jpg' }],
_instanceUrl: SONARR_BASE
};
const ADMIN_SERIES = {
id: 43,
title: 'Admin Show',
titleSlug: 'admin-show',
tags: [2],
path: '/tv/admin-show',
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/admin-poster.jpg' }],
_instanceUrl: SONARR_BASE
};
const ADMIN_SAB_SLOT = {
filename: 'Admin.Show.S01E01.720p',
nzbname: 'Admin.Show.S01E01.720p',
nzo_id: 'SABnzbd_nzo_admin001',
percentage: '40',
mb: '500',
mbmissing: '300',
size: '500 MB',
status: 'Downloading',
storage: '/downloads/Admin.Show.S01E01.720p',
timeleft: '0:08:00'
};
const ADMIN_SONARR_QUEUE_RECORD = {
id: 1002,
title: 'Admin.Show.S01E01.720p',
seriesId: 43,
series: ADMIN_SERIES,
episodeId: 502,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: SONARR_BASE,
_instanceKey: 'sonarr-api-key'
};
const MOVIE = {
id: 99,
title: 'My Movie',
titleSlug: 'my-movie-2024',
tags: [10],
path: '/movies/my-movie',
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/movie-poster.jpg' }],
_instanceUrl: RADARR_BASE
};
const SAB_QUEUE_SLOT = {
filename: 'My.Show.S01E01.720p',
nzbname: 'My.Show.S01E01.720p',
nzo_id: 'SABnzbd_nzo_abc123',
percentage: '55',
mb: '700',
mbmissing: '315',
size: '700 MB',
status: 'Downloading',
storage: '/downloads/My.Show.S01E01.720p',
timeleft: '0:10:00'
};
const SAB_MOVIE_SLOT = {
filename: 'My.Movie.2024.1080p',
nzbname: 'My.Movie.2024.1080p',
nzo_id: 'SABnzbd_nzo_xyz999',
percentage: '80',
mb: '4000',
mbmissing: '800',
size: '4 GB',
status: 'Downloading',
timeleft: '0:05:00'
};
const SONARR_QUEUE_RECORD = {
id: 1001,
title: 'My.Show.S01E01.720p',
seriesId: 42,
series: SERIES,
episodeId: 501,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: SONARR_BASE,
_instanceKey: 'sonarr-api-key'
};
const RADARR_QUEUE_RECORD = {
id: 2001,
title: 'My.Movie.2024.1080p',
movieId: 99,
movie: MOVIE,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: RADARR_BASE,
_instanceKey: 'radarr-api-key'
};
const QBIT_TORRENT = {
hash: 'abc123def456',
name: 'My.Show.S01E01.720p',
state: 'downloading',
progress: 0.55,
size: 734003200,
downloaded: 403701760,
uploadSpeed: 0,
downloadSpeed: 1024000,
eta: 300,
savePath: '/downloads/torrents/',
addedOn: Date.now() / 1000 - 7200
};
// ---------------------------------------------------------------------------
// Cache seeding helpers
// ---------------------------------------------------------------------------
function seedEmptyCache() {
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll: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', [], CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
}
function seedSabSonarrCache() {
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [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);
}
function seedSabRadarrCache() {
cache.set('poll:sab-queue', { slots: [SAB_MOVIE_SLOT], status: 'Downloading', speed: '5 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, 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);
}
function seedQbittorrentSonarrCache() {
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [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', [QBIT_TORRENT], CACHE_TTL);
}
function invalidatePollCache() {
const keys = [
'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:ombi-requests'
];
for (const k of keys) cache.invalidate(k);
}
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
function interceptEmbyLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
}
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
interceptEmbyLogin(userBody, authBody);
const res = await request(app)
.post('/api/auth/login')
.send({ username: userBody.Name, password: 'pw' });
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
}
// CSRF token must be sent with state-changing (POST) requests that go through
// the verifyCsrf middleware. GET requests under /api/dashboard do not need it.
async function csrfHeaders(app) {
const csrfRes = await request(app).get('/api/auth/csrf');
const token = csrfRes.body.csrfToken;
const cookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
return { token, cookie };
}
// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
beforeAll(() => {
process.env.EMBY_URL = EMBY_BASE;
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
});
afterAll(() => {
delete process.env.EMBY_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
});
beforeEach(() => {
seedEmptyCache();
});
afterEach(() => {
nock.cleanAll();
invalidatePollCache();
cache.invalidate('emby:users');
});
// ---------------------------------------------------------------------------
// GET /api/dashboard/user-downloads
// ---------------------------------------------------------------------------
describe('GET /api/dashboard/user-downloads', () => {
describe('authentication', () => {
it('returns 401 when not logged in', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/dashboard/user-downloads');
expect(res.status).toBe(401);
});
});
describe('empty cache', () => {
it('returns empty downloads array for authenticated user', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedEmptyCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.user).toBe('alice');
expect(res.body.isAdmin).toBe(false);
expect(res.body.downloads).toEqual([]);
});
});
describe('SABnzbd + Sonarr queue matching', () => {
it('returns a series download when SAB slot title matches Sonarr queue record', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const downloads = res.body.downloads;
expect(downloads.length).toBeGreaterThanOrEqual(1);
const dl = downloads[0];
expect(dl.type).toBe('series');
expect(dl.seriesName).toBe('My Show');
expect(dl.coverArt).toBe('https://img.test/poster.jpg');
});
it('includes admin-only fields when user is admin', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Seed a SAB slot + Sonarr record tagged for 'admin' so the admin user gets a result
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);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.arrQueueId).toBe(1002);
expect(dl.arrType).toBe('sonarr');
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
expect(dl.downloadPath).toBeDefined();
});
it('includes ARR ID fields for non-admin user (for blocklist functionality)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
// ARR IDs are now exposed to non-admins for blocklist functionality
expect(dl.arrQueueId).toBe(1001);
expect(dl.arrType).toBe('sonarr');
// But sensitive fields remain admin-only
expect(dl.arrInstanceKey).toBeUndefined();
expect(dl.arrLink).toBeUndefined();
expect(dl.downloadPath).toBeUndefined();
expect(dl.targetPath).toBeUndefined();
});
it('does not return downloads tagged for a different user', async () => {
const app = createApp({ skipRateLimits: true });
// Login as 'bob' — series is tagged 'alice'
interceptEmbyLogin({ Id: 'uid-bob', Name: 'bob', Policy: { IsAdministrator: false } }, { AccessToken: 'tok-bob', User: { Id: 'uid-bob', Name: 'bob' } });
const res1 = await request(app)
.post('/api/auth/login')
.send({ username: 'bob', password: 'pw' });
const bobCookies = res1.headers['set-cookie'];
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', bobCookies);
expect(res.status).toBe(200);
expect(res.body.downloads).toEqual([]);
});
});
describe('SABnzbd + Radarr queue matching', () => {
it('returns a movie download when SAB slot title matches Radarr queue record', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabRadarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'movie');
expect(dl).toBeDefined();
expect(dl.movieName).toBe('My Movie');
});
});
describe('qBittorrent + Sonarr queue matching', () => {
it('returns a series download from a qBittorrent torrent', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedQbittorrentSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.seriesName).toBe('My Show');
});
});
describe('paused queue', () => {
it('reports Paused status when SAB queue is paused', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Paused', speed: '0' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [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);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
if (dl) {
expect(dl.status).toBe('Paused');
expect(dl.speed).toBe(0);
}
});
});
describe('showAll (admin)', () => {
it('returns downloads for all tagged users when showAll=true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
seedSabSonarrCache();
// Stub Emby users list used by getEmbyUsers()
nock(EMBY_BASE)
.get('/Users')
.reply(200, [{ Name: 'alice' }, { Name: 'bob' }]);
const res = await request(app)
.get('/api/dashboard/user-downloads?showAll=true')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.isAdmin).toBe(true);
// tagBadges should be present on results when showAll is active
const dl = res.body.downloads.find(d => d.allTags && d.allTags.length > 0);
if (dl) {
expect(Array.isArray(dl.tagBadges)).toBe(true);
}
});
it('non-admin cannot use showAll — still filtered to their own tags', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads?showAll=true')
.set('Cookie', cookies);
expect(res.status).toBe(200);
// Non-admin: showAll has no effect, tagBadges must be absent
const dl = res.body.downloads[0];
if (dl) expect(dl.tagBadges).toBeUndefined();
});
});
describe('refreshRate tracking', () => {
it('accepts refreshRate query parameter without error', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedEmptyCache();
const res = await request(app)
.get('/api/dashboard/user-downloads?refreshRate=10000')
.set('Cookie', cookies);
expect(res.status).toBe(200);
});
});
describe('SABnzbd history matching', () => {
it('returns a series download matched from SAB history + Sonarr history', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const historySlot = {
name: 'My.Show.S01E02.720p',
status: 'Completed',
size: '700 MB',
completed_time: Math.floor(Date.now() / 1000) - 3600
};
const sonarrHistoryRecord = {
id: 9001,
sourceTitle: 'My.Show.S01E02.720p',
seriesId: 42,
series: { ...SERIES },
episode: { seasonNumber: 1, episodeNumber: 2, title: 'Episode 2' }
};
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [historySlot] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [sonarrHistoryRecord] }, 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);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.seriesName).toBe('My Show');
});
});
describe('import issues', () => {
it('includes importIssues when Sonarr record has warning status', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const problemRecord = {
...SONARR_QUEUE_RECORD,
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['No suitable video file found'] }]
};
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [problemRecord] }, 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);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.importIssues);
expect(dl).toBeDefined();
expect(dl.importIssues).toContain('No suitable video file found');
expect(dl.canBlocklist).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// GET /api/status
// ---------------------------------------------------------------------------
describe('GET /api/status', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/status');
expect(res.status).toBe(401);
});
it('returns 403 for non-admin users', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
// Status route fetches Sonarr/Radarr notifications — intercept them
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/admin/i);
});
it('returns server/cache/polling/webhook stats for admin', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.server).toBeDefined();
expect(typeof res.body.server.uptimeSeconds).toBe('number');
expect(typeof res.body.server.nodeVersion).toBe('string');
expect(res.body.cache).toBeDefined();
expect(res.body.polling).toBeDefined();
expect(res.body.webhooks).toBeDefined();
});
it('handles Sonarr/Radarr notification check failures gracefully', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.webhooks.sonarr).toBeNull();
expect(res.body.webhooks.radarr).toBeNull();
});
it('reports webhook configured=true when Sofarr notification exists', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
nock(SONARR_BASE)
.get('/api/v3/notification')
.reply(200, [{ name: 'Sofarr', implementation: 'Webhook' }]);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.webhooks.sonarr).toBeDefined();
expect(res.body.webhooks.sonarr.enabled).toBe(true);
});
});
// ---------------------------------------------------------------------------
// GET /api/dashboard/cover-art
// ---------------------------------------------------------------------------
describe('GET /api/dashboard/cover-art', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/dashboard/cover-art?url=https://img.test/poster.jpg');
expect(res.status).toBe(401);
});
it('returns 400 when url parameter is missing', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/dashboard/cover-art')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/missing url/i);
});
it('returns 400 for an invalid URL', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/dashboard/cover-art?url=not-a-url')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid url/i);
});
it('returns 400 for non-http/https scheme', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/dashboard/cover-art?url=ftp://img.test/poster.jpg')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/http/i);
});
it('returns 400 when remote URL is not an image', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock('https://img.test')
.get('/notanimage.html')
.reply(200, '<html/>', { 'content-type': 'text/html' });
const res = await request(app)
.get('/api/dashboard/cover-art?url=https://img.test/notanimage.html')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not an image/i);
});
it('returns 502 when remote image fetch fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock('https://img.test')
.get('/poster.jpg')
.replyWithError('connection refused');
const res = await request(app)
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
.set('Cookie', cookies);
expect(res.status).toBe(502);
});
it('proxies an image and sets correct headers', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock('https://img.test')
.get('/poster.jpg')
.reply(200, Buffer.from('FAKEJPEG'), { 'content-type': 'image/jpeg' });
const res = await request(app)
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/image\/jpeg/);
expect(res.headers['cache-control']).toMatch(/max-age=86400/);
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
});
// ---------------------------------------------------------------------------
// POST /api/dashboard/blocklist-search
// ---------------------------------------------------------------------------
describe('POST /api/dashboard/blocklist-search', () => {
async function getAuthHeaders(app, userBody = EMBY_ADMIN_USER, authBody = EMBY_ADMIN_AUTH) {
const { cookies, csrf } = await loginAs(app, userBody, authBody);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
return { cookies, csrfCookie, csrf };
}
it('returns 403 (CSRF missing) when not authenticated', async () => {
// verifyCsrf middleware fires before requireAuth for POST routes;
// an unauthenticated POST without CSRF headers gets 403, not 401.
const app = createApp({ skipRateLimits: true });
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
});
it('returns 403 for non-admin user without qualifying conditions', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// 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')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.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);
});
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='));
// 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('; '))
.set('X-CSRF-Token', csrf)
.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);
});
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='));
// 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)
.delete('/api/v3/queue/1')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_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)
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('returns 400 when required fields are missing', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/missing/i);
});
it('returns 400 for invalid arrType', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1, arrType: 'invalid', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/sonarr or radarr/i);
});
it('calls Sonarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// 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')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_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)
.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);
});
it('calls Radarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// 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')
.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)
.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);
});
it('returns 502 when Sonarr DELETE request fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// 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')
.query(true)
.replyWithError('connection refused');
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(502);
});
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);
// 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')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_BASE)
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.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);
});
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// 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')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_BASE)
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.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);
});
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);
});
});
// ---------------------------------------------------------------------------
// GET /api/dashboard/stream (SSE)
// ---------------------------------------------------------------------------
const OMBI_STREAM_FIXTURE = {
movie: [
{ id: 1, title: 'Movie 1', requestedUser: { userName: 'alice' } },
{ id: 2, title: 'Movie 2', requestedUser: { userName: 'bob' } }
],
tv: [
{ id: 3, title: 'TV 1', requestedUser: { userName: 'alice' } },
{ id: 4, title: 'TV 2', requestedUser: { userName: 'bob' } }
]
};
describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () => {
let appInstance;
beforeEach(() => {
appInstance = createApp({ skipRateLimits: true });
// Seed basic cached values to prevent on-demand poll
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll: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', [], CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
});
it('filters Ombi requests by user when showAll is false', async () => {
const { cookies } = await loginAs(appInstance);
// Explicitly seed the cache to ensure we have the fixtures in memory
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll: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', [], CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
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:');
// Parse the data payload
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
expect(data.user).toBe('alice');
expect(data.ombiRequests.movie).toHaveLength(1);
expect(data.ombiRequests.movie[0].title).toBe('Movie 1');
expect(data.ombiRequests.tv).toHaveLength(1);
expect(data.ombiRequests.tv[0].title).toBe('TV 1');
});
it('returns all Ombi requests when admin with showAll is true', async () => {
const { cookies } = await loginAs(appInstance, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Explicitly seed the cache to ensure we have the fixtures in memory
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll: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', [], CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
nock(EMBY_BASE)
.get('/Users')
.reply(200, [EMBY_USER, EMBY_ADMIN_USER]);
const res = await request(appInstance)
.get('/api/dashboard/stream')
.query({ showAll: 'true', testClose: 'true' })
.set('Cookie', cookies);
expect(res.status).toBe(200);
const text = res.text;
expect(text).toContain('data:');
// Parse the data payload
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
expect(data.user).toBe('admin');
expect(data.ombiRequests.movie).toHaveLength(2);
expect(data.ombiRequests.tv).toHaveLength(2);
});
});