diff --git a/lib/env.js b/lib/env.js index 94c0ea74..06b3d2d6 100644 --- a/lib/env.js +++ b/lib/env.js @@ -6,5 +6,6 @@ module.exports = { CREATE_PR_COMMENT: process.env.CREATE_PR_COMMENT || 'true', CREATE_ERROR_ISSUE: process.env.CREATE_ERROR_ISSUE || 'true', BLOCK_REPO_RENAME_BY_HUMAN: process.env.BLOCK_REPO_RENAME_BY_HUMAN || 'false', - FULL_SYNC_NOP: process.env.FULL_SYNC_NOP === 'true' + FULL_SYNC_NOP: process.env.FULL_SYNC_NOP === 'true', + VALIDATE_CONFIG_SCHEMA: process.env.VALIDATE_CONFIG_SCHEMA === 'true' } diff --git a/lib/settings.js b/lib/settings.js index 20e71167..112d291c 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -1,16 +1,21 @@ const path = require('path') const { Eta } = require('eta') -const commetMessageTemplate = require('./commentmessage') +const commentMessageTemplate = require('./commentmessage') const errorTemplate = require('./error') const Glob = require('./glob') const NopCommand = require('./nopcommand') const MergeDeep = require('./mergeDeep') -const Archive = require('./plugins/archive') const env = require('./env') const CONFIG_PATH = env.CONFIG_PATH const eta = new Eta({ views: path.join(__dirname) }) const SCOPE = { ORG: 'org', REPO: 'repo' } // Determine if the setting is a org setting or repo setting const yaml = require('js-yaml'); +const Ajv = require('ajv/dist/2020') +const schema = require('../schema/dereferenced/settings.json'); + +const ajv = new Ajv({ + strict: false +}) class Settings { static fileCache = {}; @@ -99,6 +104,34 @@ class Settings { } } this.mergeDeep = new MergeDeep(this.log, this.github, [], this.configvalidators, this.overridevalidators) + + const filePath = path.posix.join(env.CONFIG_PATH, env.SETTINGS_FILE_PATH) + const validation = this.validateConfig(this.config) + + if (validation instanceof Error) { + this.appendToResults([{ + type: 'ERROR', + plugin: 'settings', + repo: this.repo.repo, + action: { + msg: `${filePath} fails schema validation:\n${validation.message}` + } + }]) + + this.logError(`${filePath} fails schema validation:\n${validation.message}`) + } + } + + validateConfig (config) { + if (env.VALIDATE_CONFIG_SCHEMA) { + const valid = ajv.validate(schema, config) + + if (!valid) { + return new Error(ajv.errorsText(ajv.errors)) + } + } + + return true } // Create a check in the Admin repo for safe-settings. @@ -232,7 +265,7 @@ class Settings { ` - const renderedCommentMessage = await eta.renderString(commetMessageTemplate, stats) + const renderedCommentMessage = await eta.renderString(commentMessageTemplate, stats) if (env.CREATE_PR_COMMENT === 'true') { const summary = ` @@ -246,7 +279,7 @@ ${this.results.reduce((x, y) => { error = true return `${x} ❗ ${y.action.msg} ${y.plugin} ${prettify(y.repo)} ${prettify(y.action.additions)} ${prettify(y.action.deletions)} ${prettify(y.action.modifications)} ` - } else if (y.action.additions === null && y.action.deletions === null && y.action.modifications === null) { + } else if (y.action?.additions === null && y.action.deletions === null && y.action.modifications === null) { return `${x}` } else { if (y.action === undefined) { @@ -644,7 +677,7 @@ ${this.results.reduce((x, y) => { /** * If repo param is null load configs for all repos * If repo param is null and suborg change, load configs for suborg repos only - * If repo partam is not null, load the config for a specific repo + * If repo param is not null, load the config for a specific repo * @param {*} repo repo param * @returns repoConfigs object */ @@ -844,6 +877,20 @@ ${this.results.reduce((x, y) => { } } + const validation = this.validateConfig(content) + + if (validation instanceof Error) { + this.appendToResults([{ + type: 'ERROR', + plugin: 'settings', + repo: this.repo.repo, + action: { + msg: `${namespacedFilepath} fails schema validation:\n${validation.message}` + } + }]) + this.logError(`${namespacedFilepath} fails schema validation:\n${validation.message}`) + } + return content } catch (e) { if (e.status === 404) { diff --git a/package-lock.json b/package-lock.json index cf04008a..b3be4f6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^12.0.2", "@probot/adapter-aws-lambda-serverless": "^4.0.3", + "ajv": "^8.17.1", "deepmerge": "^4.3.1", "eta": "^3.5.0", "js-yaml": "^4.1.0", @@ -753,6 +754,23 @@ "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, + "license": "MIT", + "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/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -808,6 +826,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3908,15 +3933,15 @@ } }, "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==", + "license": "MIT", "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", @@ -6178,6 +6203,23 @@ "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, + "license": "MIT", + "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/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -6259,6 +6301,13 @@ "node": ">=8" } }, + "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, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6525,8 +6574,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-glob": { "version": "3.3.2", @@ -6583,6 +6631,22 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "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" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -9597,10 +9661,10 @@ "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==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -11771,6 +11835,15 @@ "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-in-the-middle": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", @@ -13291,6 +13364,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index e765fb80..5bdc24bf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^12.0.2", "@probot/adapter-aws-lambda-serverless": "^4.0.3", + "ajv": "^8.17.1", "deepmerge": "^4.3.1", "eta": "^3.5.0", "js-yaml": "^4.1.0", diff --git a/test/unit/lib/settings-validation.test.js b/test/unit/lib/settings-validation.test.js new file mode 100644 index 00000000..4842b431 --- /dev/null +++ b/test/unit/lib/settings-validation.test.js @@ -0,0 +1,47 @@ +/* eslint-disable no-undef */ +const Settings = require('../../../lib/settings') + +jest.mock('../../../lib/env', () => ({ + VALIDATE_CONFIG_SCHEMA: true, + CONFIG_PATH: '.github', + SETTINGS_FILE_PATH: 'settings.yml' +})) + +const context = { + log: { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn() + }, + payload: { + installation: { + id: 123 + } + } +} + +describe('Settings Validation Tests', () => { + it('should validate config schema when VALIDATE_CONFIG_SCHEMA is true', async () => { + const settings = new Settings(true, context, {}, { + repositories: { + has_wiki: 'nonsense' + } + }, 'main', 'github') + + expect(settings.results).toContainEqual(expect.objectContaining({ + action: { + msg: expect.stringContaining('has_wiki must be boolean') + } + })) + }) + + it('should pass valid config', async () => { + const settings = new Settings(false, context, {}, { + repositories: { + has_wiki: true + } + }, 'main', 'github') + + expect(settings.results).toEqual([]) + }) +}) // Settings Tests diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js index 39aac216..2bae6267 100644 --- a/test/unit/lib/settings.test.js +++ b/test/unit/lib/settings.test.js @@ -82,8 +82,6 @@ repository: } } - - mockRepo = { owner: 'test', repo: 'test-repo' } mockRef = 'main' mockSubOrg = 'frontend'