From dcb4a280e9c66605eef5d2eebc6ba10013437688 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Fri, 3 Jan 2025 15:14:46 +1300 Subject: [PATCH] Completed verification stage --- src/SaaStack.sln.DotSettings | 4 + .../src/appSettingsJsonFileReader.spec.ts | 37 +++ .../src/appSettingsJsonFileReader.ts | 25 ++ .../src/configurationSets.spec.ts | 254 ++++++++++++++---- .../src/configurationSets.ts | 156 +++++++++-- .../VariableSubstitution/src/index.ts | 18 +- .../src/settingsFile.spec.ts | 116 ++++++++ .../VariableSubstitution/src/settingsFile.ts | 81 ++++++ .../src/testing/__data/emptyjson.json | 2 + .../src/testing/__data/invalidjson.txt | 1 + 10 files changed, 618 insertions(+), 76 deletions(-) create mode 100644 src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.spec.ts create mode 100644 src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.ts create mode 100644 src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.spec.ts create mode 100644 src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.ts create mode 100644 src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/emptyjson.json create mode 100644 src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/invalidjson.txt diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 33553fd2..d5e1aa05 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -1533,6 +1533,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1735,6 +1736,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1819,6 +1821,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1832,6 +1835,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.spec.ts b/src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.spec.ts new file mode 100644 index 00000000..e334f967 --- /dev/null +++ b/src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.spec.ts @@ -0,0 +1,37 @@ +import {AppSettingsJsonFileReader} from "./appSettingsJsonFileReader"; + +describe('AppSettingsJsonFileReader', () => { + + it('should throw, when file does not exist', async () => { + + const reader = new AppSettingsJsonFileReader(); + + try { + await reader.readAppSettingsFile('nonexistent.json'); + } catch (error) { + expect(error.message).toMatch("File 'nonexistent.json' cannot be read from disk, possibly it does not exist, or is not accessible?"); + } + }); + + it('should throw, when file is not JSON', async () => { + + const reader = new AppSettingsJsonFileReader(); + const path = `${__dirname}/testing/__data/invalidjson.txt`; + + try { + await reader.readAppSettingsFile(path); + } catch (error) { + expect(error.message).toMatch(`File '${path}' does not contain valid JSON: SyntaxError: Unexpected token 'i', \"invalid\" is not valid JSON`); + } + }); + + it('should return file, when file has no variables', async () => { + + const reader = new AppSettingsJsonFileReader(); + const path = `${__dirname}/testing/__data/emptyjson.json`; + + const file = await reader.readAppSettingsFile(path); + + expect(file).toEqual({}); + }); +}); \ No newline at end of file diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.ts b/src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.ts new file mode 100644 index 00000000..7d9ddb32 --- /dev/null +++ b/src/Tools.GitHubActions/VariableSubstitution/src/appSettingsJsonFileReader.ts @@ -0,0 +1,25 @@ +import fs from "node:fs"; + +export interface IAppSettingsJsonFileReader { + readAppSettingsFile(path: string): Promise; +} + +export class AppSettingsJsonFileReader implements IAppSettingsJsonFileReader { + async readAppSettingsFile(path: string): Promise { + let data: any; + try { + const result = await fs.promises.readFile(path); + data = Buffer.from(result); + } catch (error) { + throw new Error(`File '${path}' cannot be read from disk, possibly it does not exist, or is not accessible?`); + } + + const raw = data.toString(); + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`File '${path}' does not contain valid JSON: ${error}`); + } + } + +} \ No newline at end of file diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.spec.ts b/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.spec.ts index 9b4d8a80..332e0402 100644 --- a/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.spec.ts +++ b/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.spec.ts @@ -1,6 +1,7 @@ import {ConfigurationSets} from "./configurationSets"; import {ILogger} from "./logger"; import {IGlobPatternParser} from "./globPatternParser"; +import {IAppSettingsJsonFileReader} from "./appSettingsJsonFileReader"; describe('ConfigurationSets', () => { const logger: jest.Mocked = { @@ -9,67 +10,226 @@ describe('ConfigurationSets', () => { error: jest.fn(), }; - it('should warn and be empty when constructed with no files', async () => { + describe('create', () => { + + it('should warn and be empty when constructed with no files', async () => { + + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve([])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn() + }; + + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); + + expect(sets.hasNone).toBe(true); + expect(globParser.parseFiles).toHaveBeenCalledWith([]); + expect(jsonFileReader.readAppSettingsFile).not.toHaveBeenCalled(); + expect(logger.warning).toHaveBeenCalledWith('No settings files found in this repository, using the glob patterns: '); + }); + + it('should create a single set, when has one file at the root', async () => { + + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve(["afile.json"])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn(_path => Promise.resolve({ + "aname": "avalue" + })), + }; + + + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); + + expect(sets.hasNone).toBe(false); + expect(sets.length).toBe(1); + expect(sets.sets[0].hostProjectPath).toEqual("."); + expect(sets.sets[0].settingFiles.length).toEqual(1); + expect(sets.sets[0].settingFiles[0].path).toEqual("afile.json"); + expect(sets.sets[0].definedVariables).toEqual(["aname"]); + expect(sets.sets[0].requiredVariables).toEqual([]); + expect(globParser.parseFiles).toHaveBeenCalledWith([]); + expect(jsonFileReader.readAppSettingsFile).toHaveBeenCalledWith("afile.json"); + expect(logger.info).toHaveBeenCalledWith('Found settings files, in these hosts:\n\t.:\n\t\tafile.json'); + }); + + it('should create a single set, when has one file in a directory', async () => { + + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve(["apath/afile.json"])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn(_path => Promise.resolve({ + "aname": "avalue" + })), + }; + + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); + + expect(sets.hasNone).toBe(false); + expect(sets.length).toBe(1); + expect(sets.sets[0].hostProjectPath).toEqual("apath"); + expect(sets.sets[0].settingFiles.length).toEqual(1); + expect(sets.sets[0].settingFiles[0].path).toEqual("apath/afile.json"); + expect(sets.sets[0].definedVariables).toEqual(["aname"]); + expect(sets.sets[0].requiredVariables).toEqual([]); + expect(globParser.parseFiles).toHaveBeenCalledWith([]); + expect(jsonFileReader.readAppSettingsFile).toHaveBeenCalledWith("apath/afile.json"); + expect(logger.info).toHaveBeenCalledWith('Found settings files, in these hosts:\n\tapath:\n\t\tafile.json'); + }); + + it('should create a single set, when has many files in same directory', async () => { + + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve(["apath/afile1.json", "apath/afile2.json"])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn() + }; + jsonFileReader.readAppSettingsFile + .mockResolvedValueOnce({ + "aname1": "avalue" + }) + .mockResolvedValueOnce({ + "aname2": "avalue" + }); + + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); + + expect(sets.hasNone).toBe(false); + expect(sets.length).toBe(1); + expect(sets.sets[0].hostProjectPath).toEqual("apath"); + expect(sets.sets[0].settingFiles.length).toEqual(2); + expect(sets.sets[0].settingFiles[0].path).toEqual("apath/afile1.json"); + expect(sets.sets[0].settingFiles[1].path).toEqual("apath/afile2.json"); + expect(sets.sets[0].definedVariables).toEqual(["aname1", "aname2"]); + expect(sets.sets[0].requiredVariables).toEqual([]); + expect(globParser.parseFiles).toHaveBeenCalledWith([]); + expect(jsonFileReader.readAppSettingsFile).toHaveBeenCalledWith("apath/afile1.json"); + expect(jsonFileReader.readAppSettingsFile).toHaveBeenCalledWith("apath/afile2.json"); + expect(logger.info).toHaveBeenCalledWith('Found settings files, in these hosts:\n\tapath:\n\t\tafile1.json,\n\t\tafile2.json'); + }); + + it('should create a single set with combined required variables, when both files have Required settings', async () => { + + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve(["apath/afile1.json", "apath/afile2.json"])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn() + }; + jsonFileReader.readAppSettingsFile + .mockResolvedValueOnce({ + "aname1": "avalue", + "Required": ["arequired1", "arequired2"] + }) + .mockResolvedValueOnce({ + "aname2": "avalue", + "Required": ["arequired2", "arequired3"] + }); + + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); + + expect(sets.hasNone).toBe(false); + expect(sets.length).toBe(1); + expect(sets.sets[0].hostProjectPath).toEqual("apath"); + expect(sets.sets[0].settingFiles.length).toEqual(2); + expect(sets.sets[0].settingFiles[0].path).toEqual("apath/afile1.json"); + expect(sets.sets[0].settingFiles[1].path).toEqual("apath/afile2.json"); + expect(sets.sets[0].definedVariables).toEqual(["aname1", "aname2"]); + expect(sets.sets[0].requiredVariables).toEqual(["arequired1", "arequired2", "arequired3"]); + expect(globParser.parseFiles).toHaveBeenCalledWith([]); + expect(jsonFileReader.readAppSettingsFile).toHaveBeenCalledWith("apath/afile1.json"); + expect(jsonFileReader.readAppSettingsFile).toHaveBeenCalledWith("apath/afile2.json"); + expect(logger.info).toHaveBeenCalledWith('Found settings files, in these hosts:\n\tapath:\n\t\tafile1.json,\n\t\tafile2.json'); + }); + }); - const globParser: jest.Mocked = { - parseFiles: jest.fn(matches => Promise.resolve([])), - }; + describe('verifyConfiguration', () => { + it('should return true, when there are no sets', async () => { - const sets = await ConfigurationSets.create(logger, globParser, ''); + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve([])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn() + }; - expect(sets.hasNone).toBe(true); - expect(globParser.parseFiles).toHaveBeenCalledWith([]); - expect(logger.warning).toHaveBeenCalledWith('No settings files found in this repository, using the glob patterns: '); - }); + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); - it('should create a single set, when has one file at the root', async () => { + const result = sets.verifyConfiguration(); - const globParser: jest.Mocked = { - parseFiles: jest.fn(matches => Promise.resolve(["afile.json"])), - }; + expect(result).toBe(true) + }); - const sets = await ConfigurationSets.create(logger, globParser, ''); + it('should return true, when the set contains no required', async () => { - expect(sets.hasNone).toBe(false); - expect(sets.length).toBe(1); - expect(sets.sets[0].HostProjectPath).toEqual("."); - expect(sets.sets[0].SettingFiles).toEqual(["afile.json"]); - expect(sets.sets[0].RequiredVariables).toEqual([]); - expect(globParser.parseFiles).toHaveBeenCalledWith([]); - expect(logger.info).toHaveBeenCalledWith('Found settings files:\n\t.:\n\t\tafile.json'); - }); + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve(["apath/afile1.json"])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn() + }; + jsonFileReader.readAppSettingsFile + .mockResolvedValueOnce({ + "aname": "avalue" + }); - it('should create a single set, when has one file in a directory', async () => { + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); - const globParser: jest.Mocked = { - parseFiles: jest.fn(matches => Promise.resolve(["apath/afile.json"])), - }; + const result = sets.verifyConfiguration(); - const sets = await ConfigurationSets.create(logger, globParser, ''); + expect(result).toBe(true); + expect(logger.info).toHaveBeenCalledWith(`Verification of host 'apath' completed successfully`); + }); - expect(sets.hasNone).toBe(false); - expect(sets.length).toBe(1); - expect(sets.sets[0].HostProjectPath).toEqual("apath"); - expect(sets.sets[0].SettingFiles).toEqual(["apath/afile.json"]); - expect(sets.sets[0].RequiredVariables).toEqual([]); - expect(globParser.parseFiles).toHaveBeenCalledWith([]); - expect(logger.info).toHaveBeenCalledWith('Found settings files:\n\tapath:\n\t\tafile.json'); - }); + it('should return false, when the set contains required variable, and not exists', async () => { + + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve(["apath/afile1.json"])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn() + }; + jsonFileReader.readAppSettingsFile + .mockResolvedValueOnce({ + "aname": "avalue", + "Required": ["arequired"] + }); + + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); + + const result = sets.verifyConfiguration(); + + expect(result).toBe(false); + expect(logger.info).not.toHaveBeenCalledWith(`Verification of host 'apath' completed successfully`); + expect(logger.error).toHaveBeenCalledWith(`Required variable 'arequired' is not defined in any of the settings files of this host!`); + expect(logger.error).toHaveBeenCalledWith(`Verification of host 'apath' failed, there is at least one missing required variable!`); + }); + + it('should return true, when the set contains required variable, and exists', async () => { + + const globParser: jest.Mocked = { + parseFiles: jest.fn(_matches => Promise.resolve(["apath/afile1.json"])), + }; + const jsonFileReader: jest.Mocked = { + readAppSettingsFile: jest.fn() + }; + jsonFileReader.readAppSettingsFile + .mockResolvedValueOnce({ + "aname": "avalue", + "Required": ["aname"] + }); - it('should create a single set, when has many files in same directory', async () => { + const sets = await ConfigurationSets.create(logger, globParser, jsonFileReader, ''); - const globParser: jest.Mocked = { - parseFiles: jest.fn(matches => Promise.resolve(["apath/afile1.json", "apath/afile2.json"])), - }; + const result = sets.verifyConfiguration(); - const sets = await ConfigurationSets.create(logger, globParser, ''); + expect(result).toBe(true); + expect(logger.info).toHaveBeenCalledWith(`Verification of host 'apath' completed successfully`); + }); - expect(sets.hasNone).toBe(false); - expect(sets.length).toBe(1); - expect(sets.sets[0].HostProjectPath).toEqual("apath"); - expect(sets.sets[0].SettingFiles).toEqual(["apath/afile1.json", "apath/afile2.json"]); - expect(sets.sets[0].RequiredVariables).toEqual([]); - expect(globParser.parseFiles).toHaveBeenCalledWith([]); - expect(logger.info).toHaveBeenCalledWith('Found settings files:\n\tapath:\n\t\tafile1.json,\n\t\tafile2.json'); }); }); \ No newline at end of file diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.ts b/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.ts index 52f2344f..ccc5f327 100644 --- a/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.ts +++ b/src/Tools.GitHubActions/VariableSubstitution/src/configurationSets.ts @@ -1,18 +1,77 @@ import * as path from "node:path"; import {ILogger} from "./logger"; import {IGlobPatternParser} from "./globPatternParser"; +import {ISettingsFile, SettingsFile} from "./settingsFile"; +import {IAppSettingsJsonFileReader} from "./appSettingsJsonFileReader"; -interface ConfigurationSet { - HostProjectPath: string; - SettingFiles: string[]; - RequiredVariables: string[]; +interface IConfigurationSet { + readonly hostProjectPath: string; + readonly settingFiles: ISettingsFile[]; + readonly requiredVariables: string[]; + readonly definedVariables: string[]; + + accumulateRequiredVariables(variables: string[]): void; + + accumulateDefinedVariables(variables: string[]): void; +} + +class ConfigurationSet implements IConfigurationSet { + constructor(hostProjectPath: string, settingFiles: ISettingsFile[]) { + this._hostProjectPath = hostProjectPath; + this._settingFiles = settingFiles; + this._requiredVariables = []; + this._definedVariables = []; + } + + _hostProjectPath: string; + + get hostProjectPath(): string { + return this._hostProjectPath; + } + + _settingFiles: ISettingsFile[]; + + get settingFiles(): ISettingsFile[] { + return this._settingFiles; + } + + _requiredVariables: string[]; + + get requiredVariables(): string[] { + return this._requiredVariables; + } + + _definedVariables: string[]; + + get definedVariables(): string[] { + return this._definedVariables; + } + + accumulateRequiredVariables(variables: string[]) { + for (const variable of variables) { + if (!this._requiredVariables.includes(variable)) { + this._requiredVariables.push(variable); + } + } + } + + accumulateDefinedVariables(variables: string[]) { + for (const variable of variables) { + if (!this._definedVariables.includes(variable)) { + this._definedVariables.push(variable); + } + } + } } + export class ConfigurationSets { - sets: ConfigurationSet[] = []; + sets: IConfigurationSet[] = []; + private logger: ILogger; - private constructor(sets: ConfigurationSet[]) { + private constructor(logger: ILogger, sets: IConfigurationSet[]) { this.sets = sets; + this.logger = logger; } get hasNone(): boolean { @@ -23,37 +82,86 @@ export class ConfigurationSets { return this.sets.length; } - public static async create(logger: ILogger, globParser: IGlobPatternParser, globPattern: string): Promise { + public static async create(logger: ILogger, globParser: IGlobPatternParser, jsonFileReader: IAppSettingsJsonFileReader, globPattern: string): Promise { const matches = globPattern.length > 0 ? globPattern.split(',') : []; const files = await globParser.parseFiles(matches); if (files.length === 0) { logger.warning(`No settings files found in this repository, using the glob patterns: ${globPattern}`); - return new ConfigurationSets([]); + return new ConfigurationSets(logger, []); } const sets: ConfigurationSet[] = []; - files.forEach(file => { - const hostProjectPath: string = path.dirname(file); - const requiredVariables: string[] = []; //TODO: we need to harvest the required properties from the JSON file (if they exist) + for (const file of files) { + await ConfigurationSets.accumulateFilesIntoSets(jsonFileReader, sets, file); + } + + for (const set of sets) { + ConfigurationSets.accumulateAllVariablesForSet(set); + } + + const allFiles = sets.map(set => `${set.hostProjectPath}:\n\t\t${set.settingFiles.map(file => path.basename(file.path)).join(',\n\t\t')}`).join(',\n\t'); + logger.info(`Found settings files, in these hosts:\n\t${allFiles}`); + + return new ConfigurationSets(logger, sets); + } + + private static async accumulateFilesIntoSets(jsonFileReader: IAppSettingsJsonFileReader, sets: ConfigurationSet[], file: string) { + + const hostProjectPath: string = path.dirname(file); + + const set = sets.find(set => set.hostProjectPath.includes(hostProjectPath)); + if (set) { + const setting = await SettingsFile.create(jsonFileReader, file); + set.settingFiles.push(setting); + + } else { + const setting = await SettingsFile.create(jsonFileReader, file); + const settingFiles: ISettingsFile[] = [setting]; + sets.push(new ConfigurationSet(hostProjectPath, settingFiles)); + } + } + + private static accumulateAllVariablesForSet(set: ConfigurationSet) { + + const files = set.settingFiles; + for (const file of files) { + set.accumulateDefinedVariables(file.variables); + if (file.hasRequired) { + set.accumulateRequiredVariables(file.requiredVariables); + } + } + } - const set = sets.find(x => x.HostProjectPath.includes(hostProjectPath)); - if (set) { - set.SettingFiles.push(file); + verifyConfiguration(): boolean { + if (this.sets.length === 0) { + return true; + } + + let setsVerified = true; + for (const set of this.sets) { + this.logger.info(`Verifying settings files in host: '${set.hostProjectPath}'`); + let setVerified = true; + for (const requiredVariable of set.requiredVariables) { + if (!set.definedVariables.includes(requiredVariable)) { + setVerified = false; + this.logger.error(`Required variable '${requiredVariable}' is not defined in any of the settings files of this host!`); + } + } + + if (!setVerified) { + this.logger.error(`Verification of host '${set.hostProjectPath}' failed, there is at least one missing required variable!`); + setsVerified = false; } else { - const settingFiles: string[] = [file]; - sets.push({ - HostProjectPath: hostProjectPath, - SettingFiles: settingFiles, - RequiredVariables: requiredVariables - }); + this.logger.info(`Verification of host '${set.hostProjectPath}' completed successfully`); } - }); + } - const allFiles = sets.map(set => `${set.HostProjectPath}:\n\t\t${set.SettingFiles.map(file => path.basename(file)).join(',\n\t\t')}`).join(',\n\t'); - logger.info(`Found settings files:\n\t${allFiles}`); + if (!setsVerified) { + this.logger.error("Verification of the settings files failed! there are missing required variables in at least one of the hosts!"); + } - return new ConfigurationSets(sets); + return setsVerified; } } \ No newline at end of file diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/index.ts b/src/Tools.GitHubActions/VariableSubstitution/src/index.ts index 3f4c1d2a..3e749b3d 100644 --- a/src/Tools.GitHubActions/VariableSubstitution/src/index.ts +++ b/src/Tools.GitHubActions/VariableSubstitution/src/index.ts @@ -3,6 +3,7 @@ import * as github from "@actions/github"; import {ConfigurationSets} from "./configurationSets"; import {Logger} from "./logger"; import {GlobPatternParser} from "./globPatternParser"; +import {AppSettingsJsonFileReader} from "./appSettingsJsonFileReader"; run().then(); @@ -21,13 +22,20 @@ async function run() { const variables = JSON.parse(variablesParam); const globParser = new GlobPatternParser(); - const configurationSets = await ConfigurationSets.create(logger, globParser, filesParam); + const jsonFileReader = new AppSettingsJsonFileReader(); + const configurationSets = await ConfigurationSets.create(logger, globParser, jsonFileReader, filesParam); if (configurationSets.hasNone) { - logger.info('Skipping variable substitution'); + logger.info('No settings files found, skipping variable substitution'); + return; } else { - // Get the JSON webhook payload for the event that triggered the workflow - // const payload = JSON.stringify(github.context.payload, undefined, 2); - // core.info(`The event payload: ${payload}`); + const verified = configurationSets.verifyConfiguration(); + if (!verified) { + return; + } + + //TODO: Substitute: walk each configuration set, for each settings file: + // 1. substitute the variables with the values from the variables/secrets (in-memory), then + // 2. write those (in-memory) files to disk (in their original locations). } } catch (error: unknown) { let message = "An unknown error occurred while processing the settings files"; diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.spec.ts b/src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.spec.ts new file mode 100644 index 00000000..99f968fc --- /dev/null +++ b/src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.spec.ts @@ -0,0 +1,116 @@ +import {SettingsFile} from "./settingsFile"; +import {IAppSettingsJsonFileReader} from "./appSettingsJsonFileReader"; + +describe('SettingsFile', () => { + it('should return file, when file has no variables', async () => { + + const path = `${__dirname}/testing/__data/emptyjson.json`; + const reader: jest.Mocked = { + readAppSettingsFile: jest.fn().mockResolvedValue({}), + }; + + const file = await SettingsFile.create(reader, path); + + expect(file.path).toEqual(path); + expect(file.variables.length).toEqual(0); + }); + + it('should return file, when file has multi-level variables', async () => { + + const path = `${__dirname}/testing/__data/appsettings.json`; + const reader: jest.Mocked = { + readAppSettingsFile: jest.fn().mockResolvedValue( + { + "Level1.1": { + "Level2.1": "avalue1", + "Level2.2": { + "Level3.1": "avalue2" + } + }, + "Level1.2": "avalue4" + }), + }; + + const file = await SettingsFile.create(reader, path); + + expect(file.path).toEqual(path); + expect(file.variables.length).toEqual(3); + expect(file.variables[0]).toEqual("Level1.1:Level2.1"); + expect(file.variables[1]).toEqual("Level1.1:Level2.2:Level3.1"); + expect(file.variables[2]).toEqual("Level1.2"); + expect(file.hasRequired).toEqual(false); + }); + + it('should return file without Required, when file has incorrectly typed Required value', async () => { + + const path = `${__dirname}/testing/__data/appsettings.json`; + const reader: jest.Mocked = { + readAppSettingsFile: jest.fn().mockResolvedValue( + { + "Level1": "avalue", + "Required": "arequired" + }), + }; + + const file = await SettingsFile.create(reader, path); + + expect(file.path).toEqual(path); + expect(file.variables.length).toEqual(2); + expect(file.variables[0]).toEqual("Level1"); + expect(file.variables[1]).toEqual("Required"); + expect(file.hasRequired).toEqual(false); + }); + + it('should return file without Required, when file has incorrectly nested Required value', async () => { + + const path = `${__dirname}/testing/__data/appsettings.json`; + const reader: jest.Mocked = { + readAppSettingsFile: jest.fn().mockResolvedValue( + { + "Level1": { + "Required": [ + "arequired1", + "arequired2", + "arequired3" + ] + } + }), + }; + + const file = await SettingsFile.create(reader, path); + + expect(file.path).toEqual(path); + expect(file.variables.length).toEqual(3); + expect(file.variables[0]).toEqual("Level1:Required:0"); + expect(file.variables[1]).toEqual("Level1:Required:1"); + expect(file.variables[2]).toEqual("Level1:Required:2"); + expect(file.hasRequired).toEqual(false); + }); + + it('should return file with Required, when file has correct Required values', async () => { + + const path = `${__dirname}/testing/__data/appsettings.json`; + const reader: jest.Mocked = { + readAppSettingsFile: jest.fn().mockResolvedValue( + { + "Level1": "avalue", + "Required": [ + "arequired1", + "arequired2", + "arequired3" + ] + }), + }; + + const file = await SettingsFile.create(reader, path); + + expect(file.path).toEqual(path); + expect(file.variables.length).toEqual(1); + expect(file.variables[0]).toEqual("Level1"); + expect(file.hasRequired).toEqual(true); + expect(file.requiredVariables.length).toEqual(3); + expect(file.requiredVariables[0]).toEqual("arequired1"); + expect(file.requiredVariables[1]).toEqual("arequired2"); + expect(file.requiredVariables[2]).toEqual("arequired3"); + }) +}); \ No newline at end of file diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.ts b/src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.ts new file mode 100644 index 00000000..2e19cb22 --- /dev/null +++ b/src/Tools.GitHubActions/VariableSubstitution/src/settingsFile.ts @@ -0,0 +1,81 @@ +import {IAppSettingsJsonFileReader} from "./appSettingsJsonFileReader"; + +export interface ISettingsFile { + readonly path: string; + readonly variables: string[]; + readonly hasRequired: boolean; + readonly requiredVariables: string[]; +} + +export class SettingsFile implements ISettingsFile { + private constructor(path: string, variables: string[], requiredVariables: string[]) { + this._path = path; + this._variables = variables; + this._requiredVariables = requiredVariables; + } + + _path: string; + + get path(): string { + return this._path; + } + + _variables: string[]; + + get variables(): string[] { + return this._variables; + } + + _requiredVariables: string[]; + + get requiredVariables(): string[] { + return this._requiredVariables; + } + + get hasRequired(): boolean { + return this._requiredVariables.length > 0; + } + + public static async create(reader: IAppSettingsJsonFileReader, path: string): Promise { + + const json = await reader.readAppSettingsFile(path); + const variables: string[] = []; + const requiredVariables: string[] = []; + SettingsFile.scrapeVariablesRecursively(json, variables, requiredVariables); + return new SettingsFile(path, variables, requiredVariables); + } + + private static scrapeVariablesRecursively(json: any, variables: string[], requiredVariables: string[], prefix: string = "") { + for (const key in json) { + if (json.hasOwnProperty(key)) { + const element = json[key]; + const nextPrefix = SettingsFile.createVariablePath(prefix, key); + if (typeof element === "object") { + if (SettingsFile.isTopLevelRequiredKey(element, key, prefix)) { + for (let index = 0; index < element.length; index++) { + const requiredKey = element[index]; + requiredVariables.push(requiredKey); + } + } else { + SettingsFile.scrapeVariablesRecursively(element, variables, requiredVariables, nextPrefix); + } + } else { + variables.push(nextPrefix); + } + } + } + } + + private static createVariablePath(prefix: string, key: string): string { + if (prefix === "") { + return key; + } + return `${prefix}:${key}`; + } + + private static isTopLevelRequiredKey(element: any, key: string, prefix: string): boolean { + return (key === "required" || key === "Required") + && Array.isArray(element) + && prefix === ""; + } +} \ No newline at end of file diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/emptyjson.json b/src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/emptyjson.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/emptyjson.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/invalidjson.txt b/src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/invalidjson.txt new file mode 100644 index 00000000..e466dcbd --- /dev/null +++ b/src/Tools.GitHubActions/VariableSubstitution/src/testing/__data/invalidjson.txt @@ -0,0 +1 @@ +invalid \ No newline at end of file