Files
sofarr/tests/integration/ombi.test.js
T
gronod 9862c0555c
Build and Push Docker Image / build (push) Successful in 47s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m24s
CI / Security audit (push) Successful in 1m47s
CI / Swagger Validation & Coverage (push) Successful in 2m11s
CI / Tests & coverage (push) Successful in 2m27s
Handle Ombi API object-format requestedUser field
The Ombi API returns requestedUser as an OmbiUser object instead of a string.
Add extractRequestedUser helper to extract username from various fields
(alias, userAlias, userName, normalizedUserName) with fallback to legacy string format.
Update client and server routes to use the helper for consistent username extraction.
2026-05-21 21:56:36 +01:00

693 lines
22 KiB
JavaScript

// 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');
// ---------------------------------------------------------------------------
// 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: 'admin' }, requestedByAlias: 'admin', type: 'movie' }
],
tv: [
{ id: 3, title: 'Test Show', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'tv' },
{ id: 4, title: 'Admin Show', requestedUser: { userName: 'admin' }, requestedByAlias: 'admin', type: 'tv' }
]
};
const OMBI_WEBHOOK_CONFIG = {
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
applicationToken: 'test-ombi-api-key'
};
const OMBI_WEBHOOK_METRICS = {
eventCount: 10,
pollsSkipped: 5,
lastEventTimestamp: 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.skip('GET /api/ombi/requests', () => {
let app;
beforeEach(() => {
app = makeApp();
setupOmbiRequestMocks();
});
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('admin');
expect(res.body.requests.tv).toHaveLength(1);
expect(res.body.requests.tv[0].requestedUser.userName).toBe('admin');
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('admin');
});
it.skip('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.skip('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.skip('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.skip('handles object-format requestedUser with alias field', async () => {
const requestsWithAlias = [
{ id: 1, title: 'Test Movie', requestedUser: { alias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
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.skip('handles object-format requestedUser with userName field', async () => {
const requestsWithUserName = [
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
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.skip('handles object-format requestedUser with userAlias field', async () => {
const requestsWithUserAlias = [
{ id: 1, title: 'Test Movie', requestedUser: { userAlias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
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.skip('handles object-format requestedUser with normalizedUserName field', async () => {
const requestsWithNormalizedUserName = [
{ id: 1, title: 'Test Movie', requestedUser: { normalizedUserName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
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.skip('handles requestedUser as null gracefully', async () => {
const requestsWithNullUser = [
{ id: 1, title: 'Test Movie', requestedUser: null, requestedByAlias: 'otheruser', type: 'movie' }
];
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.skip('handles requestedUser as empty object gracefully', async () => {
const requestsWithEmptyObject = [
{ id: 1, title: 'Test Movie', requestedUser: {}, requestedByAlias: 'testuser', type: 'movie' }
];
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(0);
expect(res.body.total).toBe(0);
});
});
// ---------------------------------------------------------------------------
// 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 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 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)
.post('/api/v1/Settings/notifications/webhook')
.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`);
expect(res.body.applicationToken).toBe('test-ombi-key');
});
it('handles Ombi API errors gracefully', async () => {
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 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', async () => {
nock(SOFARR_BASE)
.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');
});
});