// Copyright (c) 2026 Gordon Bolton. MIT License. /** * Integration tests for server/routes/ombi.js * * Strategy: * - createApp({ skipRateLimits: true }) for a real Express instance * - nock intercepts Emby auth so we can obtain a valid session cookie * - Mock cache.getWebhookMetrics() for webhook status endpoint * - nock intercepts Ombi API calls for webhook status/test endpoints * * Covers: * GET /api/ombi/requests — auth guard, showAll parameter, user filtering (skipped - requires complex arrRetrieverRegistry mocking) * GET /api/ombi/webhook/status — auth guard, extended response with triggers and stats * POST /api/ombi/webhook/enable — auth guard, Ombi configuration check * POST /api/ombi/webhook/test — auth guard, Ombi configuration check, test webhook */ import request from 'supertest'; import nock from 'nock'; import { beforeEach, afterEach, vi } from 'vitest'; import { createRequire } from 'module'; import { createApp } from '../../server/app.js'; const require = createRequire(import.meta.url); const cache = require('../../server/utils/cache.js'); const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js'); // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const EMBY_BASE = 'https://emby.test'; const OMBI_BASE = 'https://ombi.test'; const SOFARR_BASE = 'https://sofarr.test'; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const EMBY_AUTH_BODY = { AccessToken: 'test-emby-token-abc123', User: { Id: 'user-id-001', Name: 'TestUser' } }; const EMBY_USER_BODY = { Id: 'user-id-001', Name: 'TestUser', Policy: { IsAdministrator: false } }; const EMBY_ADMIN_BODY = { Id: 'admin-id-001', Name: 'AdminUser', Policy: { IsAdministrator: true } }; const OMBI_REQUESTS = { movie: [ { id: 1, title: 'Test Movie', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }, { id: 2, title: 'Admin Movie', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'movie' } ], tv: [ { id: 3, title: 'Test Show', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'tv' }, { id: 4, title: 'Admin Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv' } ] }; const OMBI_WEBHOOK_CONFIG = { enabled: true, webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`, applicationToken: 'test-ombi-api-key' }; const OMBI_WEBHOOK_METRICS = { eventsReceived: 10, pollsSkipped: 5, lastWebhookTimestamp: 1716326400000 }; // --------------------------------------------------------------------------- // Helper functions // --------------------------------------------------------------------------- function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) { nock(EMBY_BASE) .post('/Users/authenticatebyname') .reply(200, EMBY_AUTH_BODY); nock(EMBY_BASE) .get(/\/Users\//) .reply(200, userBody); } function setupOmbiRequestMocks(movieRequests = OMBI_REQUESTS.movie, tvRequests = OMBI_REQUESTS.tv) { nock(OMBI_BASE) .get('/api/v1/Request/movie') .reply(200, movieRequests); nock(OMBI_BASE) .get('/api/v1/Request/tv') .reply(200, tvRequests); } function makeApp() { process.env.EMBY_URL = EMBY_BASE; process.env.OMBI_INSTANCES = JSON.stringify([ { id: 'ombi-1', name: 'Test Ombi', url: OMBI_BASE, apiKey: 'test-ombi-key' } ]); process.env.SOFARR_BASE_URL = SOFARR_BASE; process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret'; return createApp({ skipRateLimits: true }); } async function authenticateUser(app, username = 'TestUser', isAdmin = false) { const userBody = isAdmin ? EMBY_ADMIN_BODY : EMBY_USER_BODY; interceptSuccessfulLogin(userBody); const res = await request(app) .post('/api/auth/login') .send({ username, password: 'password' }); const cookies = res.headers['set-cookie']; const csrfToken = res.body.csrfToken; return { cookies, csrfToken }; } // --------------------------------------------------------------------------- // Setup/Teardown // --------------------------------------------------------------------------- beforeEach(() => { vi.clearAllMocks(); nock.cleanAll(); }); afterEach(() => { nock.cleanAll(); delete process.env.EMBY_URL; delete process.env.OMBI_INSTANCES; delete process.env.SOFARR_BASE_URL; delete process.env.SOFARR_WEBHOOK_SECRET; }); // --------------------------------------------------------------------------- // GET /api/ombi/requests // --------------------------------------------------------------------------- describe('GET /api/ombi/requests', () => { let app; beforeEach(() => { app = makeApp(); setupOmbiRequestMocks(); // Reset the singleton registry so it re-initializes on each request arrRetrieverRegistry.retrievers.clear(); arrRetrieverRegistry.initialized = false; }); afterEach(() => { arrRetrieverRegistry.retrievers.clear(); arrRetrieverRegistry.initialized = false; }); it('returns 401 when not authenticated', async () => { const res = await request(app) .get('/api/ombi/requests') .expect(401); expect(res.body.error).toBe('Not authenticated'); }); it('returns user-filtered requests for non-admin users', async () => { const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.user).toBe('TestUser'); expect(res.body.isAdmin).toBe(false); expect(res.body.showAll).toBe(false); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.userName).toBe('testuser'); expect(res.body.requests.tv).toHaveLength(1); expect(res.body.requests.tv[0].requestedUser.userName).toBe('testuser'); expect(res.body.total).toBe(2); }); it('returns all requests when admin with showAll=true', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.user).toBe('AdminUser'); expect(res.body.isAdmin).toBe(true); expect(res.body.showAll).toBe(true); expect(res.body.requests.movie).toHaveLength(2); expect(res.body.requests.tv).toHaveLength(2); expect(res.body.total).toBe(4); }); it('returns user-filtered requests when admin with showAll=false', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?showAll=false') .set('Cookie', cookies) .expect(200); expect(res.body.user).toBe('AdminUser'); expect(res.body.isAdmin).toBe(true); expect(res.body.showAll).toBe(false); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser'); expect(res.body.requests.tv).toHaveLength(1); expect(res.body.requests.tv[0].requestedUser.userName).toBe('adminuser'); expect(res.body.total).toBe(2); }); it('returns user-filtered requests when admin without showAll parameter', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.showAll).toBe(false); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser'); }); it('handles case-insensitive username matching', async () => { const requestsWithMixedCase = [ { id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' }, { id: 2, title: 'Admin Movie', requestedUser: { userName: 'ADMIN' }, requestedByAlias: 'ADMIN', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithMixedCase, []); const { cookies } = await authenticateUser(app, 'testuser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.userName).toBe('TestUser'); }); it('handles missing requestedUser field gracefully', async () => { const requestsWithMissingUser = [ { id: 1, title: 'Test Movie', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithMissingUser, []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(0); expect(res.body.total).toBe(0); }); it('handles empty requests array', async () => { nock.cleanAll(); setupOmbiRequestMocks([], []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(0); expect(res.body.requests.tv).toHaveLength(0); expect(res.body.total).toBe(0); }); it('handles object-format requestedUser with alias field', async () => { const requestsWithAlias = [ { id: 1, title: 'Test Movie', requestedUser: { alias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithAlias, []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.alias).toBe('testuser'); }); it('handles object-format requestedUser with userName field', async () => { const requestsWithUserName = [ { id: 1, title: 'Test Movie', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithUserName, []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.userName).toBe('testuser'); }); it('handles object-format requestedUser with userAlias field', async () => { const requestsWithUserAlias = [ { id: 1, title: 'Test Movie', requestedUser: { userAlias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithUserAlias, []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.userAlias).toBe('testuser'); }); it('handles object-format requestedUser with normalizedUserName field', async () => { const requestsWithNormalizedUserName = [ { id: 1, title: 'Test Movie', requestedUser: { normalizedUserName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithNormalizedUserName, []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.requests.movie[0].requestedUser.normalizedUserName).toBe('testuser'); }); it('handles requestedUser as null gracefully', async () => { const requestsWithNullUser = [ { id: 1, title: 'Test Movie', requestedUser: null, requestedByAlias: 'otheruser', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithNullUser, []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(0); expect(res.body.total).toBe(0); }); it('handles requestedUser as empty object gracefully', async () => { const requestsWithEmptyObject = [ { id: 1, title: 'Test Movie', requestedUser: {}, requestedByAlias: 'testuser', type: 'movie' } ]; nock.cleanAll(); setupOmbiRequestMocks(requestsWithEmptyObject, []); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/requests') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(1); expect(res.body.total).toBe(1); }); }); // --------------------------------------------------------------------------- // GET /api/ombi/requests — query param filtering // --------------------------------------------------------------------------- const FILTERED_MOVIE_REQUESTS = [ { id: 1, title: 'The Batman', requestedDate: '2026-05-21T10:00:00.000Z', available: false, approved: true, denied: false, requested: true, theMovieDbId: 414906 }, { id: 2, title: 'Batman Returns', requestedDate: '2026-05-10T10:00:00.000Z', available: true, approved: true, denied: false, requested: true, theMovieDbId: 414907 } ]; const FILTERED_TV_REQUESTS = [ { id: 3, title: 'Superman Show', requestedDate: '2026-05-15T10:00:00.000Z', available: false, approved: false, denied: false, requested: true, theMovieDbId: 101 } ]; describe('GET /api/ombi/requests query params', () => { let app; beforeEach(() => { app = makeApp(); setupOmbiRequestMocks(FILTERED_MOVIE_REQUESTS, FILTERED_TV_REQUESTS); // Reset the singleton registry so it re-initializes on each request arrRetrieverRegistry.retrievers.clear(); arrRetrieverRegistry.initialized = false; }); afterEach(() => { if (arrRetrieverRegistry) { arrRetrieverRegistry.retrievers.clear(); arrRetrieverRegistry.initialized = false; } }); it('filters by type=movie', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?type=movie&showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie.length, `Body was: ${JSON.stringify(res.body)}`).toBe(2); expect(res.body.requests.tv).toHaveLength(0); expect(res.body.total).toBe(2); }); it('filters by type=tv', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?type=tv&showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.requests.movie).toHaveLength(0); expect(res.body.requests.tv).toHaveLength(1); expect(res.body.total).toBe(1); }); it('filters by status=pending', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?status=pending&showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.total).toBe(1); expect(res.body.requests.tv[0].title).toBe('Superman Show'); }); it('filters by status=available', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?status=available&showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.total).toBe(1); expect(res.body.requests.movie[0].title).toBe('Batman Returns'); }); it('sorts by title_asc', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?sort=title_asc&showAll=true') .set('Cookie', cookies) .expect(200); const all = [...res.body.requests.movie, ...res.body.requests.tv]; expect(all.map(r => r.title)).toEqual(['Batman Returns', 'The Batman', 'Superman Show']); }); it('sorts by title_desc', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?sort=title_desc&showAll=true') .set('Cookie', cookies) .expect(200); const all = [...res.body.requests.movie, ...res.body.requests.tv]; expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']); }); it('sorts by requestedDate_desc (default)', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?showAll=true') .set('Cookie', cookies) .expect(200); const all = [...res.body.requests.movie, ...res.body.requests.tv]; expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']); }); it('searches by title substring', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?search=bat&showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.total).toBe(2); expect(res.body.requests.movie).toHaveLength(2); expect(res.body.requests.tv).toHaveLength(0); }); it('combines multiple query params', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?type=movie&status=approved&search=bat&sort=title_asc&showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.total).toBe(1); expect(res.body.requests.movie[0].title).toBe('The Batman'); }); it('invalid sort falls back to default', async () => { const { cookies } = await authenticateUser(app, 'AdminUser', true); const res = await request(app) .get('/api/ombi/requests?sort=invalid&showAll=true') .set('Cookie', cookies) .expect(200); expect(res.body.total).toBe(3); }); }); // --------------------------------------------------------------------------- // GET /api/ombi/webhook/status // --------------------------------------------------------------------------- describe('GET /api/ombi/webhook/status', () => { let app; beforeEach(() => { app = makeApp(); }); it('returns 401 when not authenticated', async () => { const res = await request(app) .get('/api/ombi/webhook/status') .expect(401); expect(res.body.error).toBe('Not authenticated'); }); it('returns disabled status when SOFARR_BASE_URL is missing', async () => { delete process.env.SOFARR_BASE_URL; app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(false); expect(res.body.webhookUrl).toBeNull(); expect(res.body.applicationToken).toBeNull(); expect(res.body.triggers).toEqual({ requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }); expect(res.body.stats).toBeNull(); }); it('returns disabled status when SOFARR_WEBHOOK_SECRET is missing', async () => { delete process.env.SOFARR_WEBHOOK_SECRET; app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(false); expect(res.body.webhookUrl).toBeNull(); expect(res.body.applicationToken).toBeNull(); expect(res.body.triggers).toEqual({ requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }); expect(res.body.stats).toBeNull(); }); it('returns disabled status when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are missing', async () => { delete process.env.SOFARR_BASE_URL; delete process.env.SOFARR_WEBHOOK_SECRET; app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(false); expect(res.body.webhookUrl).toBeNull(); expect(res.body.applicationToken).toBeNull(); expect(res.body.triggers).toEqual({ requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }); expect(res.body.stats).toBeNull(); }); it('returns disabled status when Ombi not configured', async () => { process.env.OMBI_INSTANCES = JSON.stringify([]); app = createApp({ skipRateLimits: true }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(false); expect(res.body.webhookUrl).toBeNull(); expect(res.body.applicationToken).toBeNull(); expect(res.body.triggers).toEqual({ requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }); expect(res.body.stats).toBeNull(); }); it('returns enabled status with triggers when Ombi configured', async () => { nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(200, OMBI_WEBHOOK_CONFIG); vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(true); expect(res.body.webhookUrl).toBe(OMBI_WEBHOOK_CONFIG.webhookUrl); expect(res.body.applicationToken).toBe(OMBI_WEBHOOK_CONFIG.applicationToken); expect(res.body.triggers).toEqual({ requestAvailable: true, requestApproved: true, requestDeclined: true, requestPending: true, requestProcessing: true }); expect(res.body.stats).toBeNull(); }); it('returns stats when metrics available in cache', async () => { nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(200, OMBI_WEBHOOK_CONFIG); vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(OMBI_WEBHOOK_METRICS); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(true); expect(res.body.stats).toEqual({ eventsReceived: 10, pollsSkipped: 5, lastWebhookTimestamp: 1716326400000 }); }); it('returns disabled triggers when webhook disabled in Ombi', async () => { const disabledConfig = { ...OMBI_WEBHOOK_CONFIG, enabled: false }; nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(200, disabledConfig); vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(false); expect(res.body.triggers).toEqual({ requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }); }); it('handles Ombi API errors gracefully', async () => { nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(500, { error: 'Internal server error' }); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(500); expect(res.body.error).toBe('Failed to fetch Ombi webhook status'); }); it('handles missing webhookUrl and applicationToken in Ombi response', async () => { const incompleteConfig = { enabled: true }; nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(200, incompleteConfig); vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null); const { cookies } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .get('/api/ombi/webhook/status') .set('Cookie', cookies) .expect(200); expect(res.body.enabled).toBe(true); expect(res.body.webhookUrl).toBeNull(); expect(res.body.applicationToken).toBeNull(); }); }); // --------------------------------------------------------------------------- // POST /api/ombi/webhook/enable // --------------------------------------------------------------------------- describe('POST /api/ombi/webhook/enable', () => { let app; beforeEach(() => { app = makeApp(); }); it('returns 403 when not authenticated (CSRF check before auth)', async () => { const res = await request(app) .post('/api/ombi/webhook/enable') .expect(403); expect(res.body.error).toBe('CSRF token missing'); }); it('returns 400 when SOFARR_BASE_URL is missing', async () => { delete process.env.SOFARR_BASE_URL; app = createApp({ skipRateLimits: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/enable') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(400); expect(res.body.error).toBe('SOFARR_BASE_URL not configured'); }); it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => { delete process.env.SOFARR_WEBHOOK_SECRET; app = createApp({ skipRateLimits: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/enable') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(400); expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured'); }); it('returns 400 when Ombi not configured', async () => { process.env.OMBI_INSTANCES = JSON.stringify([]); app = createApp({ skipRateLimits: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/enable') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(400); expect(res.body.error).toBe('Ombi not configured'); }); it('enables webhook successfully', async () => { nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(200, { id: 42, enabled: false, webhookUrl: null, applicationToken: null }); nock(OMBI_BASE) .post('/api/v1/Settings/notifications/webhook', { id: 42, enabled: true, webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`, applicationToken: 'test-ombi-key' }) .reply(200, { success: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/enable') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(200); expect(res.body.success).toBe(true); expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`); expect(res.body.applicationToken).toBe('test-ombi-key'); }); it('enables webhook successfully even if GET settings fails', async () => { nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(500, { error: 'Failed to fetch settings' }); nock(OMBI_BASE) .post('/api/v1/Settings/notifications/webhook', { id: 0, enabled: true, webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`, applicationToken: 'test-ombi-key' }) .reply(200, { success: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/enable') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(200); expect(res.body.success).toBe(true); }); it('handles Ombi API errors gracefully', async () => { nock(OMBI_BASE) .get('/api/v1/Settings/notifications/webhook') .reply(200, { id: 42 }); nock(OMBI_BASE) .post('/api/v1/Settings/notifications/webhook') .reply(500, { error: 'Internal server error' }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/enable') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(500); expect(res.body.error).toBe('Failed to enable Ombi webhook'); }); }); // --------------------------------------------------------------------------- // POST /api/ombi/webhook/test // --------------------------------------------------------------------------- describe('POST /api/ombi/webhook/test', () => { let app; beforeEach(() => { app = makeApp(); }); it('returns 403 when not authenticated (CSRF check before auth)', async () => { const res = await request(app) .post('/api/ombi/webhook/test') .expect(403); expect(res.body.error).toBe('CSRF token missing'); }); it('returns 400 when SOFARR_BASE_URL is missing', async () => { delete process.env.SOFARR_BASE_URL; app = createApp({ skipRateLimits: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/test') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(400); expect(res.body.error).toBe('SOFARR_BASE_URL not configured'); }); it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => { delete process.env.SOFARR_WEBHOOK_SECRET; app = createApp({ skipRateLimits: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/test') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(400); expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured'); }); it('returns 400 when Ombi not configured', async () => { process.env.OMBI_INSTANCES = JSON.stringify([]); app = createApp({ skipRateLimits: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/test') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(400); expect(res.body.error).toBe('Ombi not configured'); }); it('sends test webhook successfully', async () => { nock(SOFARR_BASE) .post('/api/webhook/ombi') .reply(200, { received: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/test') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(200); expect(res.body.success).toBe(true); }); it('sends test webhook with correct payload', async () => { const webhookScope = nock(SOFARR_BASE) .post('/api/webhook/ombi') .reply(200, { received: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); await request(app) .post('/api/ombi/webhook/test') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(200); // Verify the request was made with correct headers and payload expect(webhookScope.isDone()).toBe(true); }); it('handles webhook send errors gracefully when both public and loopback fail', async () => { nock(SOFARR_BASE) .post('/api/webhook/ombi') .reply(500, { error: 'Internal server error' }); nock('http://127.0.0.1:3001') .post('/api/webhook/ombi') .reply(500, { error: 'Internal server error' }); nock('https://127.0.0.1:3001') .post('/api/webhook/ombi') .reply(500, { error: 'Internal server error' }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/test') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(500); expect(res.body.error).toBe('Failed to test Ombi webhook'); }); it('falls back to local loopback when public URL request fails', async () => { nock(SOFARR_BASE) .post('/api/webhook/ombi') .replyWithError('Connection refused'); nock('http://127.0.0.1:3001') .post('/api/webhook/ombi') .reply(200, { received: true }); nock('https://127.0.0.1:3001') .post('/api/webhook/ombi') .reply(200, { received: true }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const res = await request(app) .post('/api/ombi/webhook/test') .set('Cookie', cookies) .set('X-CSRF-Token', csrfToken) .expect(200); expect(res.body.success).toBe(true); }); });