From 9d91683c6ba0768ed35b79a0256bc5a2d715b05b Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Fri, 18 Apr 2025 17:53:06 -0400 Subject: [PATCH] add support for env schema, allowing users to provide environment variables for an application --- lib/index.js | 5 +- lib/schema/app-schema.json | 30 ++++ lib/validator.js | 156 +++++++++++++++++++++ package-lock.json | 116 ++++++++++++--- package.json | 2 + test/data/invalid/missing-description.json | 5 + test/data/invalid/mixed.json | 12 ++ test/data/invalid/non-slash.json | 5 + test/data/invalid/slash.json | 9 ++ test/data/valid/mixed.json | 13 ++ test/data/valid/non-slash.json | 12 ++ test/data/valid/obscure.json | 16 +++ test/data/valid/slash.json | 9 ++ test/getAppConfig.js | 110 +++++++++++++++ test/index.js | 2 + test/validator.js | 64 +++++++++ 16 files changed, 548 insertions(+), 18 deletions(-) create mode 100644 lib/schema/app-schema.json create mode 100644 lib/validator.js create mode 100644 test/data/invalid/missing-description.json create mode 100644 test/data/invalid/mixed.json create mode 100644 test/data/invalid/non-slash.json create mode 100644 test/data/invalid/slash.json create mode 100644 test/data/valid/mixed.json create mode 100644 test/data/valid/non-slash.json create mode 100644 test/data/valid/obscure.json create mode 100644 test/data/valid/slash.json create mode 100644 test/getAppConfig.js create mode 100644 test/validator.js diff --git a/lib/index.js b/lib/index.js index 9a965d7..3c681c3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,4 +1,5 @@ const Jambonz = require('./rest/jambonz'); +const { validateAppConfig, getAppConfig, schema } = require('./validator'); const initializer = (accountSid, apiKey, opts) => { return new Jambonz(accountSid, apiKey, opts); @@ -8,10 +9,12 @@ initializer.Jambonz = Jambonz; initializer.WebhookResponse = require('./jambonz/webhook-response'); initializer.WsRouter = require('./jambonz/ws-router'); initializer.WsSession = require('./jambonz/ws-session'); +initializer.validateAppConfig = validateAppConfig; +initializer.getAppConfig = getAppConfig; +initializer.appSchema = schema; initializer.handleProtocols = (protocols) => { if (!protocols.has('ws.jambonz.org')) return false; return 'ws.jambonz.org'; }; - module.exports = initializer; diff --git a/lib/schema/app-schema.json b/lib/schema/app-schema.json new file mode 100644 index 0000000..9ac0b36 --- /dev/null +++ b/lib/schema/app-schema.json @@ -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"] +} \ No newline at end of file diff --git a/lib/validator.js b/lib/validator.js new file mode 100644 index 0000000..846f2a5 --- /dev/null +++ b/lib/validator.js @@ -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 } + */ +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 +}; diff --git a/package-lock.json b/package-lock.json index ffbc163..cd139d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "@jambonz/verb-specifications": "^0.0.102", + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", "bent": "^7.3.12", "debug": "^4.3.4", "parseurl": "^1.3.3" @@ -385,6 +387,28 @@ "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": { "version": "8.51.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz", @@ -700,21 +724,36 @@ } }, "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, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "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" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1650,6 +1689,28 @@ "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": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -1741,8 +1802,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", @@ -1764,6 +1824,21 @@ "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": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -2923,10 +2998,9 @@ "dev": true }, "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 + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -3588,9 +3662,9 @@ "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "engines": { "node": ">=6" @@ -3707,6 +3781,14 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", diff --git a/package.json b/package.json index 3572264..422dd64 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "license": "MIT", "dependencies": { "@jambonz/verb-specifications": "^0.0.102", + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", "bent": "^7.3.12", "debug": "^4.3.4", "parseurl": "^1.3.3" diff --git a/test/data/invalid/missing-description.json b/test/data/invalid/missing-description.json new file mode 100644 index 0000000..9589c53 --- /dev/null +++ b/test/data/invalid/missing-description.json @@ -0,0 +1,5 @@ +{ + "text": { + "type": "string" + } +} \ No newline at end of file diff --git a/test/data/invalid/mixed.json b/test/data/invalid/mixed.json new file mode 100644 index 0000000..4df41ab --- /dev/null +++ b/test/data/invalid/mixed.json @@ -0,0 +1,12 @@ +{ + "text": { + "type": "string" + }, + "/hello": { + "text": { + "description": "Welcome message", + "type": "invalid_type", + "required": true + } + } +} \ No newline at end of file diff --git a/test/data/invalid/non-slash.json b/test/data/invalid/non-slash.json new file mode 100644 index 0000000..3f92c19 --- /dev/null +++ b/test/data/invalid/non-slash.json @@ -0,0 +1,5 @@ +{ + "text": { + "type": "invalid_type" + } +} \ No newline at end of file diff --git a/test/data/invalid/slash.json b/test/data/invalid/slash.json new file mode 100644 index 0000000..23bd1ca --- /dev/null +++ b/test/data/invalid/slash.json @@ -0,0 +1,9 @@ +{ + "/hello": { + "text": { + "description": "Welcome message", + "type": "invalid_type", + "required": true + } + } +} \ No newline at end of file diff --git a/test/data/valid/mixed.json b/test/data/valid/mixed.json new file mode 100644 index 0000000..3b55deb --- /dev/null +++ b/test/data/valid/mixed.json @@ -0,0 +1,13 @@ +{ + "text": { + "description": "Default text property", + "type": "string" + }, + "/hello": { + "text": { + "description": "Welcome message", + "type": "string", + "required": true + } + } +} \ No newline at end of file diff --git a/test/data/valid/non-slash.json b/test/data/valid/non-slash.json new file mode 100644 index 0000000..d2bbefb --- /dev/null +++ b/test/data/valid/non-slash.json @@ -0,0 +1,12 @@ +{ + "text": { + "description": "Welcome message", + "type": "string", + "required": true + }, + "timeout": { + "description": "Timeout in seconds", + "type": "number", + "default": 30 + } +} \ No newline at end of file diff --git a/test/data/valid/obscure.json b/test/data/valid/obscure.json new file mode 100644 index 0000000..9909c70 --- /dev/null +++ b/test/data/valid/obscure.json @@ -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 + } + } +} \ No newline at end of file diff --git a/test/data/valid/slash.json b/test/data/valid/slash.json new file mode 100644 index 0000000..b941a80 --- /dev/null +++ b/test/data/valid/slash.json @@ -0,0 +1,9 @@ +{ + "/hello": { + "text": { + "description": "Welcome message", + "type": "string", + "required": true + } + } +} \ No newline at end of file diff --git a/test/getAppConfig.js b/test/getAppConfig.js new file mode 100644 index 0000000..2bcc41b --- /dev/null +++ b/test/getAppConfig.js @@ -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(); +}); \ No newline at end of file diff --git a/test/index.js b/test/index.js index af9397f..8f38732 100644 --- a/test/index.js +++ b/test/index.js @@ -45,6 +45,8 @@ test('unit tests', (t) => { t.end(); }); +// Run validator tests +require('./validator'); const errInvalidInstruction = () => makeTask(logger, require('./data/bad/unknown-instruction')); const errUnknownProperty = () => makeTask(logger, require('./data/bad/unknown-property')); diff --git a/test/validator.js b/test/validator.js new file mode 100644 index 0000000..6265930 --- /dev/null +++ b/test/validator.js @@ -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(); +}); \ No newline at end of file