const express = require('express'); const request = require('supertest'); // ValidationError and NotFoundError are now properly imported in services.js // Minimal asyncHandler function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } // Mock modules that services.js requires at top-level jest.mock('../../constants', () => ({ APP: { USER_AGENTS: { PROBE: 'DashCaddy/1.0' } }, REGEX: { SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ }, TIMEOUTS: { DEFAULT: 10000 }, HTTP_STATUS: { OK: 200, CREATED: 201, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_ERROR: 500 } })); jest.mock('../../input-validator', () => ({ validateServiceConfig: jest.fn(), isValidPort: jest.fn(p => p >= 1 && p <= 65535), })); jest.mock('../../fs-helpers', () => ({ exists: jest.fn().mockResolvedValue(true), })); jest.mock('../../url-resolver', () => ({ resolveServiceUrl: jest.fn((id) => `https://${id}.test`), })); jest.mock('../../pagination', () => ({ paginate: jest.fn((data, params) => ({ data, pagination: null })), parsePaginationParams: jest.fn(() => null), })); jest.mock('../../response-helpers', () => ({ success: jest.fn((res, data, statusCode = 200) => { return res.status(statusCode).json({ success: true, ...data }); }), error: jest.fn((res, message, statusCode = 500, extra) => { return res.status(statusCode).json({ success: false, error: message, ...extra }); }), })); // errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError const { exists } = require('../../fs-helpers'); const { validateServiceConfig } = require('../../input-validator'); function createApp(depsOverride = {}) { const defaultDeps = { servicesStateManager: { read: jest.fn().mockResolvedValue([]), write: jest.fn().mockResolvedValue(), update: jest.fn(async (fn) => { const data = fn([]); return data; }), }, credentialManager: { store: jest.fn().mockResolvedValue(true), retrieve: jest.fn().mockResolvedValue(null), delete: jest.fn().mockResolvedValue(true), }, siteConfig: { tld: 'sami' }, buildServiceUrl: jest.fn(id => `https://${id}.sami`), buildDomain: jest.fn(sub => `${sub}.sami`), fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }), asyncHandler, SERVICES_FILE: '/tmp/services.json', log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, safeErrorMessage: jest.fn(err => err.message), resyncHealthChecker: jest.fn().mockResolvedValue(), caddy: { read: jest.fn().mockResolvedValue(''), modify: jest.fn().mockResolvedValue({ success: true }), generateConfig: jest.fn().mockReturnValue('generated config'), }, dns: { addRecord: jest.fn().mockResolvedValue({ success: true }), }, }; const deps = { ...defaultDeps, ...depsOverride }; const servicesRoutes = require('../../routes/services'); const app = express(); app.use(express.json()); app.use('/api', servicesRoutes(deps)); // Error handler app.use((err, req, res, next) => { const status = err.statusCode || 500; res.status(status).json({ success: false, error: err.message }); }); return { app, deps }; } describe('Services Routes', () => { beforeEach(() => { jest.clearAllMocks(); exists.mockResolvedValue(true); validateServiceConfig.mockImplementation(() => {}); // No-op (valid) }); describe('GET /api/services', () => { it('returns empty array when no services file', async () => { exists.mockResolvedValue(false); const { app } = createApp(); const res = await request(app).get('/api/services'); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); it('returns services list', async () => { const services = [ { id: 'plex', name: 'Plex' }, { id: 'radarr', name: 'Radarr' }, ]; const stateManager = { read: jest.fn().mockResolvedValue(services), write: jest.fn(), update: jest.fn(), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app).get('/api/services'); expect(res.status).toBe(200); }); }); describe('POST /api/services', () => { it('adds a new service', async () => { const stateManager = { read: jest.fn().mockResolvedValue([]), write: jest.fn(), update: jest.fn(async (fn) => fn([])), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app) .post('/api/services') .send({ id: 'plex', name: 'Plex' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(stateManager.update).toHaveBeenCalled(); }); // NOTE: POST /services validation for missing id/name is caught by the route's // try/catch block which logs the error but doesn't send a response in the else branch. // The catch block only sends a response for "already exists" errors (409). it('returns 409 when service already exists', async () => { const stateManager = { read: jest.fn().mockResolvedValue([]), write: jest.fn(), update: jest.fn(async (fn) => fn([{ id: 'plex', name: 'Plex' }])), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app) .post('/api/services') .send({ id: 'plex', name: 'Plex' }); expect(res.status).toBe(409); }); }); describe('PUT /api/services', () => { it('replaces all services', async () => { const stateManager = { read: jest.fn(), write: jest.fn().mockResolvedValue(), update: jest.fn(), }; const { app } = createApp({ servicesStateManager: stateManager }); const services = [ { id: 'plex', name: 'Plex' }, { id: 'radarr', name: 'Radarr' }, ]; const res = await request(app) .put('/api/services') .send(services); expect(res.status).toBe(200); expect(res.body.count).toBe(2); expect(stateManager.write).toHaveBeenCalledWith(services); }); it('rejects non-array body', async () => { const { app } = createApp(); const res = await request(app) .put('/api/services') .send({ id: 'plex' }); expect(res.status).toBeGreaterThanOrEqual(400); }); it('rejects services without id or name', async () => { const { app } = createApp(); const res = await request(app) .put('/api/services') .send([{ id: 'plex' }]); // missing name expect(res.status).toBeGreaterThanOrEqual(400); }); }); describe('DELETE /api/services/:id', () => { it('removes a service', async () => { const stateManager = { read: jest.fn(), write: jest.fn(), update: jest.fn(async (fn) => fn([{ id: 'plex' }, { id: 'radarr' }])), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app).delete('/api/services/plex'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); it('returns 404 when services file missing', async () => { exists.mockResolvedValue(false); const { app } = createApp(); const res = await request(app).delete('/api/services/plex'); expect(res.status).toBeGreaterThanOrEqual(404); }); }); describe('POST /api/services/:serviceId/credentials', () => { it('stores credentials', async () => { const credentialManager = { store: jest.fn().mockResolvedValue(true), retrieve: jest.fn(), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app) .post('/api/services/radarr/credentials') .send({ apiKey: 'test-key', username: 'admin', password: 'pass' }); expect(res.status).toBe(200); expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.apikey', 'test-key'); expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.username', 'admin'); expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.password', 'pass'); }); }); describe('DELETE /api/services/:serviceId/credentials', () => { it('deletes credentials', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn(), delete: jest.fn().mockResolvedValue(true), }; const { app } = createApp({ credentialManager }); const res = await request(app).delete('/api/services/radarr/credentials'); expect(res.status).toBe(200); expect(credentialManager.delete).toHaveBeenCalledWith('service.radarr.apikey'); }); }); describe('GET /api/services/:serviceId/credentials', () => { it('returns credential status', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn().mockResolvedValue(null), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app).get('/api/services/radarr/credentials'); expect(res.status).toBe(200); expect(res.body).toHaveProperty('hasApiKey'); expect(res.body).toHaveProperty('hasBasicAuth'); }); it('returns hasApiKey:true when API key exists', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn().mockImplementation((key) => { if (key === 'service.radarr.apikey') return Promise.resolve('the-key'); return Promise.resolve(null); }), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app).get('/api/services/radarr/credentials'); expect(res.status).toBe(200); expect(res.body.hasApiKey).toBe(true); }); }); // ===== SEEDHOST CREDENTIAL ENDPOINTS ===== describe('POST /api/seedhost-creds', () => { it('stores seedhost username and password', async () => { const credentialManager = { store: jest.fn().mockResolvedValue(true), retrieve: jest.fn(), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app) .post('/api/seedhost-creds') .send({ username: 'user1', password: 'pass1' }); expect(res.status).toBe(200); expect(credentialManager.store).toHaveBeenCalledWith('seedhost.username', 'user1'); expect(credentialManager.store).toHaveBeenCalledWith('seedhost.password', 'pass1'); }); it('stores per-service password when serviceId provided', async () => { const credentialManager = { store: jest.fn().mockResolvedValue(true), retrieve: jest.fn(), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app) .post('/api/seedhost-creds') .send({ username: 'user1', password: 'radarr-pass', serviceId: 'radarr' }); expect(res.status).toBe(200); expect(credentialManager.store).toHaveBeenCalledWith('seedhost.password.radarr', 'radarr-pass'); }); it('rejects missing username', async () => { const { app } = createApp(); const res = await request(app) .post('/api/seedhost-creds') .send({ password: 'pass1' }); expect(res.status).toBeGreaterThanOrEqual(400); }); }); describe('GET /api/seedhost-creds', () => { it('returns credential status with shared password', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn().mockImplementation((key) => { if (key === 'seedhost.username') return Promise.resolve('user1'); if (key === 'seedhost.password') return Promise.resolve('pass1'); return Promise.reject(new Error('not found')); }), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app).get('/api/seedhost-creds'); expect(res.status).toBe(200); expect(res.body.hasCredentials).toBe(true); expect(res.body.username).toBe('user1'); }); it('checks per-service password when serviceId provided', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn().mockImplementation((key) => { if (key === 'seedhost.username') return Promise.resolve('user1'); if (key === 'seedhost.password.radarr') return Promise.resolve('radarr-pass'); return Promise.reject(new Error('not found')); }), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app).get('/api/seedhost-creds?serviceId=radarr'); expect(res.status).toBe(200); expect(res.body.hasCredentials).toBe(true); expect(res.body.hasPassword).toBe(true); }); it('returns hasCredentials:false when nothing stored', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn().mockRejectedValue(new Error('not found')), delete: jest.fn(), }; const { app } = createApp({ credentialManager }); const res = await request(app).get('/api/seedhost-creds'); expect(res.status).toBe(200); expect(res.body.hasCredentials).toBe(false); }); }); describe('DELETE /api/seedhost-creds', () => { it('deletes per-service password', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn(), delete: jest.fn().mockResolvedValue(true), }; const { app } = createApp({ credentialManager }); const res = await request(app).delete('/api/seedhost-creds?serviceId=radarr'); expect(res.status).toBe(200); expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.password.radarr'); }); it('deletes all seedhost credentials when no serviceId', async () => { const credentialManager = { store: jest.fn(), retrieve: jest.fn(), delete: jest.fn().mockResolvedValue(true), }; const { app } = createApp({ credentialManager }); const res = await request(app).delete('/api/seedhost-creds'); expect(res.status).toBe(200); expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.username'); expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.password'); }); }); // ===== SERVICES STATUS ENDPOINT ===== describe('GET /api/services/status', () => { it('returns status for all services', async () => { const stateManager = { read: jest.fn().mockResolvedValue([ { id: 'plex', name: 'Plex' }, { id: 'radarr', name: 'Radarr' }, ]), write: jest.fn(), update: jest.fn(), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app).get('/api/services/status'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body).toHaveProperty('checkedAt'); expect(res.body).toHaveProperty('statuses'); }); it('includes internet check in statuses', async () => { const stateManager = { read: jest.fn().mockResolvedValue([]), write: jest.fn(), update: jest.fn(), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app).get('/api/services/status'); expect(res.status).toBe(200); expect(res.body.statuses).toHaveProperty('internet'); }); }); // ===== SERVICE UPDATE ENDPOINT ===== describe('POST /api/services/update', () => { it('rejects missing subdomains', async () => { const { app } = createApp(); const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: 'plex' }); // missing newSubdomain expect(res.status).toBeGreaterThanOrEqual(400); }); it('rejects invalid subdomain format', async () => { const { app } = createApp(); const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: 'INVALID!', newSubdomain: 'plex' }); expect(res.status).toBeGreaterThanOrEqual(400); }); it('rejects invalid port', async () => { const { isValidPort } = require('../../input-validator'); isValidPort.mockReturnValue(false); const { app } = createApp(); const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: 'plex', newSubdomain: 'media', port: 99999 }); expect(res.status).toBeGreaterThanOrEqual(400); }); it('updates subdomain with DNS and Caddy changes', async () => { const caddy = { read: jest.fn().mockResolvedValue('plex.sami {\n reverse_proxy localhost:32400\n}'), modify: jest.fn().mockResolvedValue({ success: true }), generateConfig: jest.fn().mockReturnValue('media.sami { reverse_proxy localhost:32400 }'), }; const dns = { getToken: jest.fn().mockReturnValue('token'), call: jest.fn().mockResolvedValue({}), createRecord: jest.fn().mockResolvedValue({}), }; const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), write: jest.fn(), update: jest.fn(async (fn) => fn([{ id: 'plex', name: 'Plex', url: 'https://plex.sami' }])), }; const { app } = createApp({ caddy, dns, servicesStateManager: stateManager, }); const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: 'plex', newSubdomain: 'media' }); expect(res.status).toBe(200); expect(res.body.results).toBeDefined(); }); }); // ===== VALIDATION / EDGE CASES ===== describe('PUT /api/services validation', () => { it('rejects services that fail validateServiceConfig', async () => { validateServiceConfig.mockImplementation(() => { const err = new Error('Bad id format'); err.errors = ['id contains invalid chars']; throw err; }); const { app } = createApp(); const res = await request(app) .put('/api/services') .send([{ id: 'bad!id', name: 'Test' }]); expect(res.status).toBe(400); }); }); describe('DELETE /api/services/:id edge cases', () => { it('returns 404 when service not in list', async () => { const stateManager = { read: jest.fn(), write: jest.fn(), update: jest.fn(async (fn) => fn([{ id: 'radarr' }])), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app).delete('/api/services/nonexistent'); expect(res.status).toBe(404); }); }); });