// 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' ]; 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('does not include admin-only fields for non-admin user', 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(); expect(dl.arrQueueId).toBeUndefined(); expect(dl.arrType).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, '', { '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', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf } = await loginAs(app); const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); 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(/admin/i); }); 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); 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); 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); 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); }); });