DC-059: claim for Hermes
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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('accepts valid customVolumes objects', () => {
|
||||
const { error } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: {
|
||||
subdomain: 'plex',
|
||||
customVolumes: [{ hostPath: '/data/movies', containerPath: '/movies' }],
|
||||
},
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('strips unknown keys in config', () => {
|
||||
const { error, value } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', isAdmin: true },
|
||||
}, { stripUnknown: true });
|
||||
expect(error).toBeUndefined();
|
||||
expect(value.config).not.toHaveProperty('isAdmin');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Joi-based request body validation middleware.
|
||||
*
|
||||
* Usage:
|
||||
* const { validateBody, schemas } = require('../utilities/validate');
|
||||
*
|
||||
* router.post('/schedule', validateBody(schemas.backupSchedule), handler);
|
||||
*
|
||||
* The middleware validates req.body against the provided Joi schema.
|
||||
* On success it replaces req.body with the validated/stripped value.
|
||||
* On failure it throws a ValidationError (caught by asyncHandler → 400).
|
||||
*
|
||||
* Schemas for destructive routes live in the `schemas` export so they
|
||||
* can be unit-tested without spinning up Express.
|
||||
*/
|
||||
|
||||
const Joi = require('joi');
|
||||
const { ValidationError } = require('./errors');
|
||||
|
||||
/**
|
||||
* Factory: returns an Express middleware that validates req.body.
|
||||
* @param {Joi.Schema} schema
|
||||
* @param {{ stripUnknown?: boolean, abortEarly?: boolean }} [opts]
|
||||
*/
|
||||
function validateBody(schema, opts = {}) {
|
||||
return (req, _res, next) => {
|
||||
const { error, value } = schema.validate(req.body, {
|
||||
stripUnknown: opts.stripUnknown ?? true,
|
||||
abortEarly: opts.abortEarly ?? false,
|
||||
convert: true,
|
||||
});
|
||||
if (error) {
|
||||
const msg = error.details.map(d => d.message).join('; ');
|
||||
throw new ValidationError(msg);
|
||||
}
|
||||
req.body = value;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reusable schema fragments ───────────────────────────────
|
||||
|
||||
const scheduleSchema = Joi.alternatives().try(
|
||||
Joi.string().valid('hourly', 'daily', 'weekly', 'monthly'),
|
||||
Joi.string().pattern(/^\d+[mh]?$/, 'custom-interval (e.g. "30m", "6h")'),
|
||||
).optional();
|
||||
|
||||
const ipOrCidr = Joi.alternatives().try(
|
||||
Joi.string().ip({ version: ['ipv4', 'ipv6'] }),
|
||||
Joi.string().regex(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/)
|
||||
.custom((value, helpers) => {
|
||||
const [, a, b, c, d, prefix] = value.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
|
||||
for (const octet of [a, b, c, d]) {
|
||||
if (parseInt(octet) > 255) return helpers.error('string.pattern.base', { name: 'IPv4 CIDR' });
|
||||
}
|
||||
if (parseInt(prefix) > 32) return helpers.error('string.pattern.base', { name: 'IPv4 CIDR' });
|
||||
return value;
|
||||
}, 'IPv4 CIDR with valid octets/prefix'),
|
||||
Joi.string().regex(/^[0-9a-fA-F:]+\/(\d{1,3})$/)
|
||||
.custom((value, helpers) => {
|
||||
const prefix = parseInt(value.split('/')[1]);
|
||||
if (prefix > 128) return helpers.error('string.pattern.base', { name: 'IPv6 CIDR' });
|
||||
return value;
|
||||
}, 'IPv6 CIDR with valid prefix'),
|
||||
);
|
||||
|
||||
// ─── Schemas for destructive routes ───────────────────────────
|
||||
|
||||
const schemas = {
|
||||
/** POST /backups/config — update backup configuration */
|
||||
backupConfigUpdate: Joi.object({
|
||||
backups: Joi.object().pattern(
|
||||
Joi.string().max(100), // appId key
|
||||
Joi.object({ // per-app backup config
|
||||
enabled: Joi.boolean().optional(),
|
||||
schedule: scheduleSchema,
|
||||
retention: Joi.object({
|
||||
keep: Joi.number().integer().min(1).max(365).optional(),
|
||||
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
||||
}).optional(),
|
||||
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
||||
destinationPath: Joi.string().max(512).optional(),
|
||||
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
|
||||
runImmediately: Joi.boolean().optional(),
|
||||
include: Joi.array().items(Joi.string().max(50)).optional(),
|
||||
destinations: Joi.array().items(Joi.object({
|
||||
type: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
||||
path: Joi.string().max(512).optional(),
|
||||
}).unknown(false)).optional(),
|
||||
}).unknown(false)
|
||||
).optional(),
|
||||
defaultRetention: Joi.object({
|
||||
keep: Joi.number().integer().min(1).max(365).optional(),
|
||||
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
||||
}).optional(),
|
||||
}),
|
||||
|
||||
/** POST /backups/schedule — create or update a scheduled backup */
|
||||
backupScheduleCreate: Joi.object({
|
||||
appId: Joi.string().min(1).max(100).required(),
|
||||
enabled: Joi.boolean().optional(),
|
||||
schedule: scheduleSchema,
|
||||
retention: Joi.object({
|
||||
keep: Joi.number().integer().min(1).max(365).optional(),
|
||||
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
||||
}).optional(),
|
||||
runImmediately: Joi.boolean().optional(),
|
||||
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
||||
destinationPath: Joi.string().max(512).optional(),
|
||||
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
|
||||
// Legacy schedule route fields
|
||||
name: Joi.string().max(100).optional(),
|
||||
}),
|
||||
|
||||
/** POST /backups/restore/:backupId — passes through to backupManager.restoreBackup */
|
||||
backupRestore: Joi.object({
|
||||
encryptionKey: Joi.string().max(512).optional(),
|
||||
restartContainers: Joi.boolean().optional(),
|
||||
// restoreBackup reads options from body; allow known control flags only
|
||||
services: Joi.boolean().optional(),
|
||||
config: Joi.boolean().optional(),
|
||||
credentials: Joi.boolean().optional(),
|
||||
volumes: Joi.boolean().optional(),
|
||||
}),
|
||||
|
||||
/** POST /backups/restore-file/:filename */
|
||||
backupRestoreFile: Joi.object({
|
||||
encryptionKey: Joi.string().max(512).optional(),
|
||||
restartContainers: Joi.boolean().optional(),
|
||||
}),
|
||||
|
||||
/** POST /apps/deploy */
|
||||
appDeploy: Joi.object({
|
||||
appId: Joi.string().min(1).max(100).required(),
|
||||
config: Joi.object({
|
||||
subdomain: Joi.string().min(1).max(63).required(),
|
||||
port: Joi.number().integer().min(1).max(65535).optional(),
|
||||
ip: Joi.string().max(45).optional(),
|
||||
useExisting: Joi.boolean().optional(),
|
||||
existingContainerId: Joi.string().max(200).optional(),
|
||||
existingPort: Joi.number().integer().min(1).max(65535).optional(),
|
||||
createDns: Joi.boolean().optional(),
|
||||
tailscaleOnly: Joi.boolean().optional(),
|
||||
allowedIPs: Joi.array().items(ipOrCidr).optional(),
|
||||
customVolumes: Joi.array().items(Joi.object({
|
||||
hostPath: Joi.string().max(500).required(),
|
||||
containerPath: Joi.string().max(500).required(),
|
||||
}).unknown(false)).optional(),
|
||||
mediaPath: Joi.string().max(500).optional(),
|
||||
resources: Joi.object({
|
||||
memory: Joi.number().min(32).max(65536).optional(),
|
||||
cpus: Joi.number().min(0.1).max(64).optional(),
|
||||
}).optional(),
|
||||
}).required(),
|
||||
}),
|
||||
|
||||
/** POST /apps/:appId/restore */
|
||||
appRestore: Joi.object({}).max(0),
|
||||
|
||||
/** POST /apps/:appId/revert/:filename */
|
||||
appRevert: Joi.object({
|
||||
encryptionKey: Joi.string().max(512).optional(),
|
||||
restartContainers: Joi.boolean().optional(),
|
||||
}),
|
||||
|
||||
/** POST /assets/upload */
|
||||
assetUpload: Joi.object({
|
||||
filename: Joi.string().min(1).max(255).required(),
|
||||
data: Joi.string().min(1).max(10 * 1024 * 1024).required(), // 10MB base64 cap
|
||||
}),
|
||||
|
||||
/** POST /assets/logo */
|
||||
logoUpload: Joi.object({
|
||||
data: Joi.string().max(10 * 1024 * 1024).optional(),
|
||||
dataDark: Joi.string().max(10 * 1024 * 1024).optional(),
|
||||
dataLight: Joi.string().max(10 * 1024 * 1024).optional(),
|
||||
position: Joi.string().valid('left', 'center', 'right').optional(),
|
||||
dashboardTitle: Joi.string().max(50).allow('').optional(),
|
||||
}).min(1),
|
||||
};
|
||||
|
||||
module.exports = { validateBody, schemas };
|
||||
Reference in New Issue
Block a user