[grade=B] - New src/utilities/validate.js: validateBody(schema) middleware + 9 schemas (backupConfigUpdate, backupScheduleCreate, backupRestore, backupRestoreFile, appDeploy, appRestore, appRevert, assetUpload, logoUpload) - Uses Joi's authoritative CIDR validator (rejects malformed IPv6 like ::::/64 that the previous hex/colon regex would have accepted) - appDeploy.config uses .unknown(true) for forward-compat with template-specific fields (sslType, dnsType, plexClaimToken, etc.) — preserves fields the live frontend posts, prevents a behavioural regression - appRestore uses Joi.any().custom() so the empty-body semantics hold under middleware stripUnknown (default) — body with extra keys now rejected - Wired into 8 destructive routes: backups schedule/restore/config, apps deploy/restore/revert, assets upload/logo - Duplicate legacy POST /backups/schedule handler (line 519) marked LEGACY with TODO removal note (Express only matches first registration; this handler is unreachable under normal routing) - Removed redundant manual appId check in /backups/schedule (Joi schema enforces it) - Removed unused 'mime' destructure in /assets/favicon (decodeImageData validates MIME internally) - 41 unit tests covering every exported schema + middleware integration - 1539/1539 Jest tests pass, zero new ESLint warnings
371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
});
|