DC-059: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

This commit is contained in:
Hermes
2026-08-08 15:20:09 -07:00
parent 55a50fdeb7
commit c1358df0ec
3 changed files with 393 additions and 0 deletions
@@ -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');
});
});
+182
View File
@@ -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 };