/** * Unit tests for the Joi validation middleware + schema definitions. * Verifies that valid inputs pass through and invalid inputs throw * ValidationError with descriptive messages. */ const { validateBody, schemas } = require('../../src/utilities/validate'); function mockReq(body) { return { body }; } describe('validateBody middleware', () => { test('passes valid body through and strips unknown keys', () => { const schema = schemas.assetUpload; const req = mockReq({ filename: 'logo.png', data: 'data:image/png;base64,abc', extra: true }); const next = jest.fn(); validateBody(schema)(req, {}, next); expect(next).toHaveBeenCalled(); expect(req.body).toHaveProperty('filename', 'logo.png'); expect(req.body).not.toHaveProperty('extra'); }); test('throws ValidationError on missing required field', () => { const req = mockReq({}); expect(() => validateBody(schemas.assetUpload)(req, {}, jest.fn())).toThrow(/filename/); }); }); describe('schemas.backupConfigUpdate', () => { test('accepts valid patch', () => { const { error, value } = schemas.backupConfigUpdate.validate({ backups: { app1: { enabled: true, schedule: 'daily' } }, defaultRetention: { keep: 7 }, }); expect(error).toBeUndefined(); expect(value).toHaveProperty('backups'); }); test('strips unknown top-level keys', () => { const { error, value } = schemas.backupConfigUpdate.validate({ backups: {}, malicious: true, }, { stripUnknown: true }); expect(error).toBeUndefined(); expect(value).not.toHaveProperty('malicious'); }); test('rejects unknown keys inside per-app backup config', () => { const { error } = schemas.backupConfigUpdate.validate({ backups: { app1: { enabled: true, schedule: 'daily', rce: 'yes' } }, }, { stripUnknown: false }); expect(error).toBeDefined(); }); test('rejects invalid retention.keep', () => { const { error } = schemas.backupConfigUpdate.validate({ defaultRetention: { keep: 0 }, }); expect(error).toBeDefined(); expect(error.details[0].message).toMatch(/keep/); }); }); describe('schemas.backupScheduleCreate', () => { test('requires appId', () => { const { error } = schemas.backupScheduleCreate.validate({}); expect(error).toBeDefined(); }); test('accepts full valid body', () => { const { error, value } = schemas.backupScheduleCreate.validate({ appId: 'plex', enabled: true, schedule: 'daily', retention: { keep: 7 }, destination: 'local', maxStorageBytes: '10GB', }); expect(error).toBeUndefined(); expect(value.appId).toBe('plex'); }); test('rejects invalid destination', () => { const { error } = schemas.backupScheduleCreate.validate({ appId: 'plex', destination: 'malicious-cloud', }); expect(error).toBeDefined(); }); test('accepts numeric custom schedule (e.g. "30m", "6h")', () => { const { error: e1 } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: '30m' }); expect(e1).toBeUndefined(); const { error: e2 } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: '6h' }); expect(e2).toBeUndefined(); }); test('rejects garbage schedule string', () => { const { error } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: 'abc' }); expect(error).toBeDefined(); }); }); describe('schemas.appDeploy', () => { test('requires appId + config.subdomain', () => { const { error } = schemas.appDeploy.validate({}); expect(error).toBeDefined(); }); test('accepts minimal valid deploy', () => { const { error, value } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex' }, }); expect(error).toBeUndefined(); expect(value.config.subdomain).toBe('plex'); }); test('rejects port out of range', () => { const { error } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', port: 99999 }, }); expect(error).toBeDefined(); }); test('accepts valid IP in allowedIPs', () => { const { error } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', allowedIPs: ['192.168.1.1', '10.0.0.0/24'] }, }); expect(error).toBeUndefined(); }); test('rejects malformed CIDR in allowedIPs', () => { const { error } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', allowedIPs: ['999.999.999.999/99'] }, }); expect(error).toBeDefined(); }); test('rejects malformed IPv6 CIDR (regression: hex/colon regex was permissive)', () => { // Old regex /^[0-9a-fA-F:]+\/(\d{1,3})$/ accepted these; Joi's authoritative // CIDR validator must reject them. const { error: e1 } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', allowedIPs: ['::::/64'] }, }); expect(e1).toBeDefined(); const { error: e2 } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', allowedIPs: ['zzzz:::/64'] }, }); expect(e2).toBeDefined(); const { error: e3 } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', allowedIPs: ['not-an-ip'] }, }); expect(e3).toBeDefined(); }); test('accepts valid IPv6 CIDR', () => { const { error } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', allowedIPs: ['2001:db8::/32'] }, }); expect(error).toBeUndefined(); }); test('accepts valid customVolumes objects', () => { const { error } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', customVolumes: [{ hostPath: '/data/movies', containerPath: '/movies' }], }, }); expect(error).toBeUndefined(); }); test('preserves unknown template-specific config fields (forward-compat)', () => { // appDeploy.config uses .unknown(true) so future template-specific fields // (e.g. a new app that posts `apiKey`, `databaseType`, ...) survive validation. const { error, value } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', aFutureTemplateField: 'xyz', apiKey: 'secret', }, }); expect(error).toBeUndefined(); expect(value.config).toHaveProperty('aFutureTemplateField', 'xyz'); expect(value.config).toHaveProperty('apiKey', 'secret'); }); test('preserves template-specific config fields (sslType, dnsType, plexClaimToken)', () => { // The frontend posts these — they must survive validation or deployments break. const { error, value } = schemas.appDeploy.validate({ appId: 'plex', config: { subdomain: 'plex', sslType: 'self-signed', dnsType: 'private', plexClaimToken: 'claim-abc-123', }, }); expect(error).toBeUndefined(); expect(value.config).toHaveProperty('sslType', 'self-signed'); expect(value.config).toHaveProperty('dnsType', 'private'); expect(value.config).toHaveProperty('plexClaimToken', 'claim-abc-123'); }); }); describe('schemas.assetUpload', () => { test('requires both fields', () => { const { error: e1 } = schemas.assetUpload.validate({ filename: 'logo.png' }); expect(e1).toBeDefined(); const { error: e2 } = schemas.assetUpload.validate({ data: 'abc' }); expect(e2).toBeDefined(); }); test('accepts valid upload', () => { const { error } = schemas.assetUpload.validate({ filename: 'logo.png', data: 'data:image/png;base64,iVBORw0KGgo=', }); expect(error).toBeUndefined(); }); }); describe('schemas.backupRestoreFile', () => { test('accepts empty body', () => { const { error } = schemas.backupRestoreFile.validate({}); expect(error).toBeUndefined(); }); test('accepts optional fields', () => { const { error } = schemas.backupRestoreFile.validate({ encryptionKey: 'secret', restartContainers: true, }); expect(error).toBeUndefined(); }); test('strips unknown keys', () => { const { error, value } = schemas.backupRestoreFile.validate({ encryptionKey: 'secret', malicious: 'yes', }, { stripUnknown: true }); expect(error).toBeUndefined(); expect(value).not.toHaveProperty('malicious'); }); }); describe('schemas.backupRestore', () => { test('accepts empty body (all fields optional)', () => { const { error, value } = schemas.backupRestore.validate({}); expect(error).toBeUndefined(); expect(value).toEqual({}); }); test('accepts known control flags', () => { const { error } = schemas.backupRestore.validate({ encryptionKey: 'secret', restartContainers: true, services: true, config: true, credentials: true, volumes: true, }); expect(error).toBeUndefined(); }); test('strips unknown keys', () => { const { error, value } = schemas.backupRestore.validate({ services: true, shellCommand: 'rm -rf /', }, { stripUnknown: true }); expect(error).toBeUndefined(); expect(value).not.toHaveProperty('shellCommand'); }); }); describe('schemas.appRestore', () => { test('accepts empty body via middleware', () => { const req = mockReq({}); const next = jest.fn(); validateBody(schemas.appRestore)(req, {}, next); expect(next).toHaveBeenCalled(); }); test('rejects body with any key via middleware', () => { const req = mockReq({ filename: 'backup.tar' }); expect(() => validateBody(schemas.appRestore)(req, {}, jest.fn())).toThrow(/empty/); }); test('rejects non-object body via middleware', () => { const req = mockReq('just a string'); expect(() => validateBody(schemas.appRestore)(req, {}, jest.fn())).toThrow(/empty/); }); }); describe('schemas.appRevert', () => { test('accepts empty body', () => { const { error } = schemas.appRevert.validate({}); expect(error).toBeUndefined(); }); test('accepts optional encryption key + restart flag', () => { const { error } = schemas.appRevert.validate({ encryptionKey: 'secret', restartContainers: true, }); expect(error).toBeUndefined(); }); test('strips unknown keys (no shell injection vector)', () => { const { error, value } = schemas.appRevert.validate({ encryptionKey: 'secret', path: '/etc/passwd', shellCommand: 'rm -rf /', }, { stripUnknown: true }); expect(error).toBeUndefined(); expect(value).not.toHaveProperty('path'); expect(value).not.toHaveProperty('shellCommand'); }); }); describe('schemas.logoUpload', () => { test('requires at least one field', () => { const { error } = schemas.logoUpload.validate({}); expect(error).toBeDefined(); }); test('accepts single data field', () => { const { error } = schemas.logoUpload.validate({ data: 'data:image/png;base64,abc' }); expect(error).toBeUndefined(); }); test('accepts dataDark + dataLight pair', () => { const { error } = schemas.logoUpload.validate({ dataDark: 'data:image/png;base64,dark', dataLight: 'data:image/png;base64,light', }); expect(error).toBeUndefined(); }); test('accepts position enum', () => { const { error } = schemas.logoUpload.validate({ data: 'data:image/png;base64,abc', position: 'center', }); expect(error).toBeUndefined(); }); test('rejects invalid position', () => { const { error } = schemas.logoUpload.validate({ data: 'data:image/png;base64,abc', position: 'diagonal', }); expect(error).toBeDefined(); }); test('rejects empty-string data fields', () => { const { error } = schemas.logoUpload.validate({ data: '' }); expect(error).toBeDefined(); }); });