7d3e6e6a47
Build and Push Docker Image / build (push) Successful in 39s
Docs Check / Markdown lint (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Failing after 1m39s
Docs Check / Mermaid diagram parse check (push) Successful in 1m59s
- tests/unit/dashboard.test.js: 58 unit tests covering all 12 pure helper functions in dashboard.js (sanitizeTagLabel, tagMatchesUser, getCoverArt, extractAllTags, extractUserTag, getImportIssues, getSonarrLink, getRadarrLink, canBlocklist, extractEpisode, gatherEpisodes, buildTagBadges) - tests/integration/dashboard.test.js: 35 integration tests for /user-downloads (SAB+Sonarr, SAB+Radarr, qBit, showAll, paused queue, history matching, importIssues, wrong-user filtering), /status (admin guard, webhook check, failure handling), /webhook-metrics, /cover-art (all validation/proxy paths), /blocklist-search (guards, Sonarr, Radarr, failure) - tests/integration/emby.test.js: 13 integration tests covering all 4 Emby routes (sessions, users, users/:id, session/:id/user) with auth guard, happy path, and upstream failure cases - tests/integration/arrRoutes.test.js: 64 integration tests for Sonarr + Radarr (queue, history, series/movies, notifications CRUD, /test, /schema, /sofarr-webhook create+update+missing-config+failure) and SABnzbd (queue, history with custom params) - vitest.config.js: raise global coverage thresholds (statements/functions/ lines 20->55, branches 8->40) to reflect improved coverage (62.5% stmts, 42.6% branches, 64.1% funcs, 65.6% lines) - tests/README.md: document new test files and update coverage table
900 lines
37 KiB
JavaScript
900 lines
37 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
/**
|
|
* Integration tests for server/routes/sonarr.js, server/routes/radarr.js,
|
|
* and server/routes/sabnzbd.js.
|
|
*
|
|
* Covers:
|
|
* Sonarr: queue, history, series, series/:id, notifications CRUD,
|
|
* notifications/test, notifications/schema, sofarr-webhook (create + update)
|
|
* Radarr: same set, movies instead of series
|
|
* SABnzbd: queue, history
|
|
*
|
|
* All routes require auth; state-changing requests (POST/PUT/DELETE) must also
|
|
* carry the CSRF token issued by GET /api/auth/csrf (verifyCsrf middleware).
|
|
*/
|
|
|
|
import request from 'supertest';
|
|
import nock from 'nock';
|
|
import { createApp } from '../../server/app.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const EMBY_BASE = 'https://emby.test';
|
|
const SONARR_BASE = 'https://sonarr.test';
|
|
const RADARR_BASE = 'https://radarr.test';
|
|
const SABNZBD_BASE = 'https://sabnzbd.test';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
|
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
|
|
|
const SONARR_QUEUE = { records: [{ id: 1, title: 'Show.S01E01', seriesId: 10 }] };
|
|
const SONARR_HISTORY = { records: [{ id: 100, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01' }] };
|
|
const SONARR_SERIES_LIST = [{ id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }];
|
|
const SONARR_SERIES_ITEM = { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] };
|
|
const SONARR_NOTIFICATIONS = [{ id: 5, name: 'Plex', implementation: 'Plex' }];
|
|
const SONARR_NOTIF_ITEM = { id: 5, name: 'Plex', implementation: 'Plex', fields: [] };
|
|
|
|
const RADARR_QUEUE = { records: [{ id: 2, title: 'Movie.2024.1080p', movieId: 20 }] };
|
|
const RADARR_HISTORY = { records: [{ id: 200, eventType: 'downloadFolderImported', sourceTitle: 'Movie.2024.1080p' }] };
|
|
const RADARR_MOVIES_LIST = [{ id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] }];
|
|
const RADARR_MOVIE_ITEM = { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] };
|
|
const RADARR_NOTIFICATIONS = [{ id: 7, name: 'Plex', implementation: 'Plex' }];
|
|
const RADARR_NOTIF_ITEM = { id: 7, name: 'Plex', implementation: 'Plex', fields: [] };
|
|
|
|
const SAB_QUEUE_RESP = { queue: { status: 'Downloading', slots: [{ filename: 'Show.S01E01', percentage: '50' }] } };
|
|
const SAB_HISTORY_RESP = { history: { slots: [{ name: 'Show.S01E01.Done', status: 'Completed' }] } };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function interceptLogin() {
|
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH);
|
|
nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER);
|
|
}
|
|
|
|
async function loginAs(app) {
|
|
interceptLogin();
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: 'alice', password: 'pw' });
|
|
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
|
|
}
|
|
|
|
async function getSessionWithCsrf(app) {
|
|
const { cookies, csrf } = await loginAs(app);
|
|
// Obtain a fresh csrf cookie as well (login already sets one, but keep consistent)
|
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
|
return { cookies, csrf, csrfCookie };
|
|
}
|
|
|
|
// Build the Cookie header for state-changing requests: session + csrf cookies
|
|
function joinCookies(sessionCookies, csrfCookie) {
|
|
const all = Array.isArray(sessionCookies) ? [...sessionCookies] : [sessionCookies];
|
|
if (csrfCookie && !all.includes(csrfCookie)) all.push(csrfCookie);
|
|
return all.join('; ');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Environment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeAll(() => {
|
|
process.env.EMBY_URL = EMBY_BASE;
|
|
process.env.SONARR_URL = SONARR_BASE;
|
|
process.env.SONARR_API_KEY = 'sk';
|
|
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
|
|
process.env.RADARR_URL = RADARR_BASE;
|
|
process.env.RADARR_API_KEY = 'rk';
|
|
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
|
|
process.env.SABNZBD_URL = SABNZBD_BASE;
|
|
process.env.SABNZBD_API_KEY = 'sabkey';
|
|
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
|
process.env.SOFARR_WEBHOOK_SECRET = 'webhook-secret-abc';
|
|
});
|
|
|
|
afterAll(() => {
|
|
delete process.env.EMBY_URL;
|
|
delete process.env.SONARR_URL;
|
|
delete process.env.SONARR_API_KEY;
|
|
delete process.env.SONARR_INSTANCES;
|
|
delete process.env.RADARR_URL;
|
|
delete process.env.RADARR_API_KEY;
|
|
delete process.env.RADARR_INSTANCES;
|
|
delete process.env.SABNZBD_URL;
|
|
delete process.env.SABNZBD_API_KEY;
|
|
delete process.env.SOFARR_BASE_URL;
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
});
|
|
|
|
afterEach(() => {
|
|
nock.cleanAll();
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SONARR ROUTES
|
|
// ===========================================================================
|
|
|
|
describe('Sonarr routes', () => {
|
|
describe('GET /api/sonarr/queue', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).get('/api/sonarr/queue');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('proxies Sonarr queue', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/queue').reply(200, SONARR_QUEUE);
|
|
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.records).toBeDefined();
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/queue/i);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sonarr/history', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).get('/api/sonarr/history');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('proxies Sonarr history with default pageSize', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/history').query(true).reply(200, SONARR_HISTORY);
|
|
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.records).toBeDefined();
|
|
});
|
|
|
|
it('passes through custom pageSize', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/history').query({ pageSize: '100' }).reply(200, SONARR_HISTORY);
|
|
const res = await request(app).get('/api/sonarr/history?pageSize=100').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sonarr/series', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).get('/api/sonarr/series');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('proxies series list', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/series').reply(200, SONARR_SERIES_LIST);
|
|
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/series').replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sonarr/series/:id', () => {
|
|
it('proxies individual series', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/series/10').reply(200, SONARR_SERIES_ITEM);
|
|
const res = await request(app).get('/api/sonarr/series/10').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.title).toBe('My Show');
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/series/999').replyWithError('not found');
|
|
const res = await request(app).get('/api/sonarr/series/999').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sonarr/notifications', () => {
|
|
it('returns 503 when no Sonarr instance configured', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
// Temporarily clear instances
|
|
const saved = process.env.SONARR_INSTANCES;
|
|
delete process.env.SONARR_INSTANCES;
|
|
delete process.env.SONARR_URL;
|
|
delete process.env.SONARR_API_KEY;
|
|
|
|
interceptLogin();
|
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
|
const cookies = loginRes.headers['set-cookie'];
|
|
|
|
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
|
expect(res.status).toBe(503);
|
|
|
|
process.env.SONARR_INSTANCES = saved;
|
|
process.env.SONARR_URL = SONARR_BASE;
|
|
process.env.SONARR_API_KEY = 'sk';
|
|
});
|
|
|
|
it('proxies notifications list', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/notification').reply(200, SONARR_NOTIFICATIONS);
|
|
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sonarr/notifications/:id', () => {
|
|
it('proxies a single notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/notification/5').reply(200, SONARR_NOTIF_ITEM);
|
|
const res = await request(app).get('/api/sonarr/notifications/5').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('Plex');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/sonarr/notifications', () => {
|
|
it('returns 403 (CSRF missing) without auth', async () => {
|
|
// verifyCsrf fires before requireAuth on POST routes — no CSRF → 403
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).post('/api/sonarr/notifications').send({});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('creates a notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 99, name: 'New' });
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ name: 'New' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('New');
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).post('/api/v3/notification').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ name: 'New' });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/sonarr/notifications/:id', () => {
|
|
it('updates a notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).put('/api/v3/notification/5').reply(200, { id: 5, name: 'Updated' });
|
|
const res = await request(app)
|
|
.put('/api/sonarr/notifications/5')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 5, name: 'Updated' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('Updated');
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).put('/api/v3/notification/5').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.put('/api/sonarr/notifications/5')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 5 });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/sonarr/notifications/:id', () => {
|
|
it('deletes a notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).delete('/api/v3/notification/5').reply(200, {});
|
|
const res = await request(app)
|
|
.delete('/api/sonarr/notifications/5')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).delete('/api/v3/notification/5').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.delete('/api/sonarr/notifications/5')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/sonarr/notifications/test', () => {
|
|
it('returns 503 when no Sonarr instance configured', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const saved = process.env.SONARR_INSTANCES;
|
|
const savedUrl = process.env.SONARR_URL;
|
|
const savedKey = process.env.SONARR_API_KEY;
|
|
delete process.env.SONARR_INSTANCES;
|
|
delete process.env.SONARR_URL;
|
|
delete process.env.SONARR_API_KEY;
|
|
|
|
interceptLogin();
|
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
|
const cookies = loginRes.headers['set-cookie'];
|
|
const csrf = loginRes.body.csrfToken;
|
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
|
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications/test')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(503);
|
|
|
|
process.env.SONARR_INSTANCES = saved;
|
|
process.env.SONARR_URL = savedUrl;
|
|
process.env.SONARR_API_KEY = savedKey;
|
|
});
|
|
|
|
it('tests a notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).post('/api/v3/notification/test').reply(200, {});
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications/test')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 5 });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 500 when test fails', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(SONARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications/test')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 5 });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sonarr/notifications/schema', () => {
|
|
it('proxies the schema', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SONARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
|
|
const res = await request(app).get('/api/sonarr/notifications/schema').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/sonarr/notifications/sofarr-webhook', () => {
|
|
it('returns 400 when SOFARR_BASE_URL is not configured', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const saved = process.env.SOFARR_BASE_URL;
|
|
delete process.env.SOFARR_BASE_URL;
|
|
|
|
interceptLogin();
|
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
|
const cookies = loginRes.headers['set-cookie'];
|
|
const csrf = loginRes.body.csrfToken;
|
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
|
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/SOFARR_BASE_URL/);
|
|
|
|
process.env.SOFARR_BASE_URL = saved;
|
|
});
|
|
|
|
it('creates a new webhook notification when none exists', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
|
|
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
|
|
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 10, name: 'Sofarr' });
|
|
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('Sofarr');
|
|
});
|
|
|
|
it('updates an existing Sofarr notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
|
|
nock(SONARR_BASE)
|
|
.get('/api/v3/notification')
|
|
.reply(200, [{ id: 10, name: 'Sofarr', implementation: 'Webhook' }]);
|
|
nock(SONARR_BASE)
|
|
.put('/api/v3/notification/10')
|
|
.reply(200, { id: 10, name: 'Sofarr' });
|
|
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('Sofarr');
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
|
|
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
|
|
|
const res = await request(app)
|
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// RADARR ROUTES
|
|
// ===========================================================================
|
|
|
|
describe('Radarr routes', () => {
|
|
describe('GET /api/radarr/queue', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).get('/api/radarr/queue');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('proxies Radarr queue', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/queue').reply(200, RADARR_QUEUE);
|
|
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.records).toBeDefined();
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/radarr/history', () => {
|
|
it('proxies Radarr history', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/history').query(true).reply(200, RADARR_HISTORY);
|
|
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/radarr/movies', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).get('/api/radarr/movies');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('proxies movies list', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/movie').reply(200, RADARR_MOVIES_LIST);
|
|
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/movie').replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/radarr/movies/:id', () => {
|
|
it('proxies a single movie', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/movie/20').reply(200, RADARR_MOVIE_ITEM);
|
|
const res = await request(app).get('/api/radarr/movies/20').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.title).toBe('My Movie');
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/movie/999').replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/radarr/movies/999').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/radarr/notifications', () => {
|
|
it('returns 503 when no Radarr instance configured', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const saved = process.env.RADARR_INSTANCES;
|
|
const savedUrl = process.env.RADARR_URL;
|
|
const savedKey = process.env.RADARR_API_KEY;
|
|
delete process.env.RADARR_INSTANCES;
|
|
delete process.env.RADARR_URL;
|
|
delete process.env.RADARR_API_KEY;
|
|
|
|
interceptLogin();
|
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
|
const cookies = loginRes.headers['set-cookie'];
|
|
|
|
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
|
expect(res.status).toBe(503);
|
|
|
|
process.env.RADARR_INSTANCES = saved;
|
|
process.env.RADARR_URL = savedUrl;
|
|
process.env.RADARR_API_KEY = savedKey;
|
|
});
|
|
|
|
it('proxies notifications list', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/notification').reply(200, RADARR_NOTIFICATIONS);
|
|
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/radarr/notifications', () => {
|
|
it('creates a Radarr notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 88, name: 'New' });
|
|
const res = await request(app)
|
|
.post('/api/radarr/notifications')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ name: 'New' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('New');
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/radarr/notifications/:id', () => {
|
|
it('updates a Radarr notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).put('/api/v3/notification/7').reply(200, { id: 7, name: 'Updated' });
|
|
const res = await request(app)
|
|
.put('/api/radarr/notifications/7')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 7, name: 'Updated' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('Updated');
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).put('/api/v3/notification/7').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.put('/api/radarr/notifications/7')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 7 });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/radarr/notifications/:id', () => {
|
|
it('deletes a Radarr notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).delete('/api/v3/notification/7').reply(200, {});
|
|
const res = await request(app)
|
|
.delete('/api/radarr/notifications/7')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).delete('/api/v3/notification/7').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.delete('/api/radarr/notifications/7')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/radarr/notifications/:id', () => {
|
|
it('proxies a single Radarr notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/notification/7').reply(200, RADARR_NOTIF_ITEM);
|
|
const res = await request(app).get('/api/radarr/notifications/7').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('Plex');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/radarr/notifications/test', () => {
|
|
it('tests a Radarr notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).post('/api/v3/notification/test').reply(200, {});
|
|
const res = await request(app)
|
|
.post('/api/radarr/notifications/test')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 7 });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 500 when test fails', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.post('/api/radarr/notifications/test')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({ id: 7 });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/radarr/notifications/schema', () => {
|
|
it('proxies the Radarr notification schema', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(RADARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
|
|
const res = await request(app).get('/api/radarr/notifications/schema').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/radarr/notifications/sofarr-webhook', () => {
|
|
it('creates a new Radarr webhook when none exists', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
|
|
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
|
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 20, name: 'Sofarr' });
|
|
|
|
const res = await request(app)
|
|
.post('/api/radarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('Sofarr');
|
|
});
|
|
|
|
it('updates an existing Sofarr Radarr notification', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
|
|
nock(RADARR_BASE)
|
|
.get('/api/v3/notification')
|
|
.reply(200, [{ id: 20, name: 'Sofarr', implementation: 'Webhook' }]);
|
|
nock(RADARR_BASE)
|
|
.put('/api/v3/notification/20')
|
|
.reply(200, { id: 20, name: 'Sofarr' });
|
|
|
|
const res = await request(app)
|
|
.post('/api/radarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 400 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const saved = process.env.SOFARR_WEBHOOK_SECRET;
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
|
|
interceptLogin();
|
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
|
const cookies = loginRes.headers['set-cookie'];
|
|
const csrf = loginRes.body.csrfToken;
|
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
|
|
|
const res = await request(app)
|
|
.post('/api/radarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/SOFARR_WEBHOOK_SECRET/);
|
|
|
|
process.env.SOFARR_WEBHOOK_SECRET = saved;
|
|
});
|
|
|
|
it('returns 500 on upstream failure', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
|
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
|
const res = await request(app)
|
|
.post('/api/radarr/notifications/sofarr-webhook')
|
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
|
.set('X-CSRF-Token', csrf)
|
|
.send({});
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SABNZBD ROUTES
|
|
// ===========================================================================
|
|
|
|
describe('SABnzbd routes', () => {
|
|
describe('GET /api/sabnzbd/queue', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).get('/api/sabnzbd/queue');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('proxies SABnzbd queue', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SABNZBD_BASE)
|
|
.get('/api')
|
|
.query({ mode: 'queue', apikey: 'sabkey', output: 'json' })
|
|
.reply(200, SAB_QUEUE_RESP);
|
|
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.queue).toBeDefined();
|
|
expect(res.body.queue.status).toBe('Downloading');
|
|
});
|
|
|
|
it('returns 500 when SABnzbd is unreachable', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SABNZBD_BASE)
|
|
.get('/api')
|
|
.query(true)
|
|
.replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/queue/i);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sabnzbd/history', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await request(app).get('/api/sabnzbd/history');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('proxies SABnzbd history with default limit', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SABNZBD_BASE)
|
|
.get('/api')
|
|
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '50' })
|
|
.reply(200, SAB_HISTORY_RESP);
|
|
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.history).toBeDefined();
|
|
});
|
|
|
|
it('passes through custom limit', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SABNZBD_BASE)
|
|
.get('/api')
|
|
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '100' })
|
|
.reply(200, SAB_HISTORY_RESP);
|
|
const res = await request(app).get('/api/sabnzbd/history?limit=100').set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('returns 500 when SABnzbd is unreachable', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const { cookies } = await loginAs(app);
|
|
nock(SABNZBD_BASE)
|
|
.get('/api')
|
|
.query(true)
|
|
.replyWithError('ECONNREFUSED');
|
|
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/history/i);
|
|
});
|
|
});
|
|
});
|