Files
dashcaddy/dashcaddy-api/__tests__/unit/validate.test.js
Hermes a667de7920
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-059: Joi validation middleware + schemas for destructive routes
[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
2026-08-08 15:39:48 -07:00

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();
});
});