add support for env schema, allowing users to provide environment variables for an application

This commit is contained in:
Dave Horton
2025-04-18 17:53:06 -04:00
parent 117f0b93e7
commit 9d91683c6b
16 changed files with 548 additions and 18 deletions

View File

@@ -1,4 +1,5 @@
const Jambonz = require('./rest/jambonz'); const Jambonz = require('./rest/jambonz');
const { validateAppConfig, getAppConfig, schema } = require('./validator');
const initializer = (accountSid, apiKey, opts) => { const initializer = (accountSid, apiKey, opts) => {
return new Jambonz(accountSid, apiKey, opts); return new Jambonz(accountSid, apiKey, opts);
@@ -8,10 +9,12 @@ initializer.Jambonz = Jambonz;
initializer.WebhookResponse = require('./jambonz/webhook-response'); initializer.WebhookResponse = require('./jambonz/webhook-response');
initializer.WsRouter = require('./jambonz/ws-router'); initializer.WsRouter = require('./jambonz/ws-router');
initializer.WsSession = require('./jambonz/ws-session'); initializer.WsSession = require('./jambonz/ws-session');
initializer.validateAppConfig = validateAppConfig;
initializer.getAppConfig = getAppConfig;
initializer.appSchema = schema;
initializer.handleProtocols = (protocols) => { initializer.handleProtocols = (protocols) => {
if (!protocols.has('ws.jambonz.org')) return false; if (!protocols.has('ws.jambonz.org')) return false;
return 'ws.jambonz.org'; return 'ws.jambonz.org';
}; };
module.exports = initializer; module.exports = initializer;

View File

@@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["string", "number", "boolean"]
},
"required": {
"type": "boolean"
},
"default": {
"oneOf": [
{ "type": "string" },
{ "type": "number" },
{ "type": "boolean" }
]
},
"enum": {
"type": "array"
},
"obscure": {
"type": "boolean"
}
},
"required": ["type", "description"]
}

156
lib/validator.js Normal file
View File

@@ -0,0 +1,156 @@
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const fs = require('fs');
const path = require('path');
const schemaPath = path.join(__dirname, 'schema/app-schema.json');
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
/**
* Gets the appropriate configuration from app.json based on the request path
* @param {Object} params - Parameters object
* @param {string} params.urlPath - The request path to match against
* @param {string} params.appJsonPath - Path to app.json file
* @returns {Object} - Result with { success: boolean, config: Object, error: string }
*/
function getAppConfig({ urlPath, appJsonPath }) {
try {
if (!appJsonPath) {
return {
success: false,
config: {},
error: 'appJsonPath is required'
};
}
if (!fs.existsSync(appJsonPath)) {
return {
success: false,
config: {},
error: `app.json file not found at ${appJsonPath}`
};
}
// Read and parse app.json
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
// Get regular (non-slash) properties
const regularProperties = Object.entries(appJson)
.filter(([key]) => !key.startsWith('/'))
.reduce((acc, [key, value]) => {
// If the property is marked as obscure, replace its value with asterisks
if (value.obscure && value.value) {
acc[key] = {
...value,
value: '*'.repeat(value.value.length)
};
} else {
acc[key] = value;
}
return acc;
}, {});
// Check for a matching path property
const pathProperty = appJson[urlPath];
if (pathProperty) {
// Combine path-specific properties with regular properties
// Path-specific properties take precedence
const mergedConfig = {
...regularProperties,
...pathProperty
};
// Handle obscure properties in path-specific config
Object.entries(pathProperty).forEach(([key, value]) => {
if (value.obscure && value.value) {
mergedConfig[key] = {
...value,
value: '*'.repeat(value.value.length)
};
}
});
return {
success: true,
config: mergedConfig,
error: null
};
}
// If no matching path property, return only the regular properties
return {
success: true,
config: regularProperties,
error: null
};
} catch (err) {
return {
success: false,
config: {},
error: `Error reading app.json: ${err.message}`
};
}
}
/**
* Validates a jambonz application configuration against the schema
* @param {Object} config - The configuration object to validate
* @returns {Object} - Validation result with { isValid: boolean, errors: Array<string> }
*/
function validateAppConfig(config) {
try {
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);
const errors = [];
// Validate each property in the config
Object.entries(config).forEach(([key, value]) => {
if (key.startsWith('/')) {
// For slash properties, validate each property within the path object
Object.entries(value).forEach(([propKey, propValue]) => {
const isValid = validate(propValue);
if (!isValid) {
errors.push(...validate.errors.map((err) => {
const errPath = err.instancePath ?
` at path ${key}.${propKey}${err.instancePath}` : ` at path ${key}.${propKey}`;
const message = err.message || 'Unknown error';
const params = err.params ?
` (${Object.entries(err.params).map(([k, v]) => `${k}: ${v}`).join(', ')})` : '';
return `${message}${errPath}${params}`;
}));
}
});
} else {
// For non-slash properties, validate the property directly
const isValid = validate(value);
if (!isValid) {
errors.push(...validate.errors.map((err) => {
const errPath = err.instancePath ? ` at path ${key}${err.instancePath}` : ` at path ${key}`;
const message = err.message || 'Unknown error';
const params = err.params ?
` (${Object.entries(err.params).map(([k, v]) => `${k}: ${v}`).join(', ')})` : '';
return `${message}${errPath}${params}`;
}));
}
}
});
return {
isValid: errors.length === 0,
errors
};
} catch (err) {
return {
isValid: false,
errors: [`Error during validation: ${err.message}`]
};
}
}
module.exports = {
validateAppConfig,
getAppConfig,
schema
};

116
package-lock.json generated
View File

@@ -10,6 +10,8 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jambonz/verb-specifications": "^0.0.102", "@jambonz/verb-specifications": "^0.0.102",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"bent": "^7.3.12", "bent": "^7.3.12",
"debug": "^4.3.4", "debug": "^4.3.4",
"parseurl": "^1.3.3" "parseurl": "^1.3.3"
@@ -385,6 +387,28 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true
},
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "8.51.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz",
@@ -700,21 +724,36 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.3",
"fast-json-stable-stringify": "^2.0.0", "fast-uri": "^3.0.1",
"json-schema-traverse": "^0.4.1", "json-schema-traverse": "^1.0.0",
"uri-js": "^4.2.2" "require-from-string": "^2.0.2"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1650,6 +1689,28 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true
},
"node_modules/espree": { "node_modules/espree": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@@ -1741,8 +1802,7 @@
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"dev": true
}, },
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
@@ -1764,6 +1824,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
@@ -2923,10 +2998,9 @@
"dev": true "dev": true
}, },
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
"dev": true
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
@@ -3588,9 +3662,9 @@
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="
}, },
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.0", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -3707,6 +3781,14 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": { "node_modules/require-main-filename": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",

View File

@@ -16,6 +16,8 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jambonz/verb-specifications": "^0.0.102", "@jambonz/verb-specifications": "^0.0.102",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"bent": "^7.3.12", "bent": "^7.3.12",
"debug": "^4.3.4", "debug": "^4.3.4",
"parseurl": "^1.3.3" "parseurl": "^1.3.3"

View File

@@ -0,0 +1,5 @@
{
"text": {
"type": "string"
}
}

View File

@@ -0,0 +1,12 @@
{
"text": {
"type": "string"
},
"/hello": {
"text": {
"description": "Welcome message",
"type": "invalid_type",
"required": true
}
}
}

View File

@@ -0,0 +1,5 @@
{
"text": {
"type": "invalid_type"
}
}

View File

@@ -0,0 +1,9 @@
{
"/hello": {
"text": {
"description": "Welcome message",
"type": "invalid_type",
"required": true
}
}
}

View File

@@ -0,0 +1,13 @@
{
"text": {
"description": "Default text property",
"type": "string"
},
"/hello": {
"text": {
"description": "Welcome message",
"type": "string",
"required": true
}
}
}

View File

@@ -0,0 +1,12 @@
{
"text": {
"description": "Welcome message",
"type": "string",
"required": true
},
"timeout": {
"description": "Timeout in seconds",
"type": "number",
"default": 30
}
}

View File

@@ -0,0 +1,16 @@
{
"apiKey": {
"description": "API key for the service",
"type": "string",
"required": true,
"obscure": true,
"value": "secret123"
},
"/hello": {
"text": {
"description": "Welcome message",
"type": "string",
"required": true
}
}
}

View File

@@ -0,0 +1,9 @@
{
"/hello": {
"text": {
"description": "Welcome message",
"type": "string",
"required": true
}
}
}

110
test/getAppConfig.js Normal file
View File

@@ -0,0 +1,110 @@
const test = require('tape');
const path = require('path');
const { getAppConfig } = require('../lib/validator');
test('getAppConfig tests', (t) => {
// Test case 1: Missing appJsonPath
t.test('should return error when appJsonPath is missing', (st) => {
const result = getAppConfig({ urlPath: '/test' });
st.equal(result.success, false, 'should not be successful');
st.equal(result.error, 'appJsonPath is required', 'should return correct error message');
st.end();
});
// Test case 2: Non-existent app.json file
t.test('should return error when app.json does not exist', (st) => {
const result = getAppConfig({
urlPath: '/test',
appJsonPath: path.join(__dirname, 'data/nonexistent.json')
});
st.equal(result.success, false, 'should not be successful');
st.ok(result.error.includes('app.json file not found'), 'should return file not found error');
st.end();
});
// Test case 3: Valid app.json with regular properties
t.test('should return regular properties when no path match', (st) => {
const result = getAppConfig({
urlPath: '/nonexistent',
appJsonPath: path.join(__dirname, 'data/valid/non-slash.json')
});
st.equal(result.success, true, 'should be successful');
st.deepEqual(result.config, {
text: {
description: 'Welcome message',
type: 'string',
required: true
}
}, 'should return correct regular properties');
st.end();
});
// Test case 4: Valid app.json with path-specific properties
t.test('should return path-specific properties when path matches', (st) => {
const result = getAppConfig({
urlPath: '/hello',
appJsonPath: path.join(__dirname, 'data/valid/slash.json')
});
st.equal(result.success, true, 'should be successful');
st.deepEqual(result.config, {
'/hello': {
text: {
description: 'Welcome message',
type: 'string',
required: true
}
}
}, 'should return correct path-specific properties');
st.end();
});
// Test case 5: Valid app.json with mixed properties
t.test('should merge regular and path-specific properties', (st) => {
const result = getAppConfig({
urlPath: '/hello',
appJsonPath: path.join(__dirname, 'data/valid/mixed.json')
});
st.equal(result.success, true, 'should be successful');
st.deepEqual(result.config, {
text: {
type: 'string'
},
'/hello': {
text: {
description: 'Welcome message',
type: 'string',
required: true
}
}
}, 'should return merged properties');
st.end();
});
// Test case 6: Obscure property handling
t.test('should obscure values when obscure flag is true', (st) => {
const result = getAppConfig({
urlPath: '/hello',
appJsonPath: path.join(__dirname, 'data/valid/obscure.json')
});
st.equal(result.success, true, 'should be successful');
st.deepEqual(result.config, {
apiKey: {
description: 'API key for the service',
type: 'string',
required: true,
obscure: true,
value: '********'
},
'/hello': {
text: {
description: 'Welcome message',
type: 'string',
required: true
}
}
}, 'should obscure sensitive values');
st.end();
});
t.end();
});

View File

@@ -45,6 +45,8 @@ test('unit tests', (t) => {
t.end(); t.end();
}); });
// Run validator tests
require('./validator');
const errInvalidInstruction = () => makeTask(logger, require('./data/bad/unknown-instruction')); const errInvalidInstruction = () => makeTask(logger, require('./data/bad/unknown-instruction'));
const errUnknownProperty = () => makeTask(logger, require('./data/bad/unknown-property')); const errUnknownProperty = () => makeTask(logger, require('./data/bad/unknown-property'));

64
test/validator.js Normal file
View File

@@ -0,0 +1,64 @@
const test = require('tape');
const { validateAppConfig } = require('../lib/validator');
const fs = require('fs');
const path = require('path');
// Load test data
const loadTestData = (category, name) => {
const filePath = path.join(__dirname, 'data', category, `${name}.json`);
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
};
test('App Config Validator', (t) => {
t.test('Valid Cases', (t) => {
// Test valid non-slash properties
const validNonSlash = loadTestData('valid', 'non-slash');
let result = validateAppConfig(validNonSlash);
t.ok(result.isValid, 'valid non-slash properties pass validation');
t.equal(result.errors.length, 0, 'no errors for valid non-slash properties');
// Test valid slash properties
const validSlash = loadTestData('valid', 'slash');
result = validateAppConfig(validSlash);
t.ok(result.isValid, 'valid slash properties pass validation');
t.equal(result.errors.length, 0, 'no errors for valid slash properties');
// Test valid mixed properties
const validMixed = loadTestData('valid', 'mixed');
result = validateAppConfig(validMixed);
t.ok(result.isValid, 'valid mixed properties pass validation');
t.equal(result.errors.length, 0, 'no errors for valid mixed properties');
t.end();
});
t.test('Invalid Cases', (t) => {
// Test invalid non-slash properties
const invalidNonSlash = loadTestData('invalid', 'non-slash');
let result = validateAppConfig(invalidNonSlash);
t.notOk(result.isValid, 'invalid non-slash properties fail validation');
t.ok(result.errors.length > 0, 'errors reported for invalid non-slash properties');
// Test invalid slash properties
const invalidSlash = loadTestData('invalid', 'slash');
result = validateAppConfig(invalidSlash);
t.notOk(result.isValid, 'invalid slash properties fail validation');
t.ok(result.errors.length > 0, 'errors reported for invalid slash properties');
// Test invalid mixed properties
const invalidMixed = loadTestData('invalid', 'mixed');
result = validateAppConfig(invalidMixed);
t.notOk(result.isValid, 'invalid mixed properties fail validation');
t.ok(result.errors.length > 0, 'errors reported for invalid mixed properties');
// Test missing description
const missingDescription = loadTestData('invalid', 'missing-description');
result = validateAppConfig(missingDescription);
t.notOk(result.isValid, 'properties without description fail validation');
t.ok(result.errors.some(err => err.includes('description')), 'error message mentions missing description');
t.end();
});
t.end();
});