47817d057b
- Update test paths from /api/status/status to /api/status - Matches the route change from /status to / in status.js
838 lines
32 KiB
JavaScript
838 lines
32 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'
|
|
];
|
|
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, '<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', 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);
|
|
});
|
|
});
|