Commit d7d3b197 authored by mohoff's avatar mohoff Committed by chapati

fix(validation): make schema validation disabled per default and explicit opt-in

parent d24ffa35
Pipeline #23834 passed with stages
in 1 minute and 32 seconds
......@@ -4,6 +4,7 @@ const StrongConfig = require('../../lib')
const strongConfig = new StrongConfig({
configPath: 'example/',
// The following line can be omitted or set to `null` to disable schema validation
schemaPath: 'example/schema.json',
})
......
......@@ -3,6 +3,7 @@ import StrongConfig from '../../src'
const strongConfig = new StrongConfig({
configPath: 'example/',
// The following line can be omitted or set to `null` to disable schema validation
schemaPath: 'example/schema.json',
})
......
......@@ -14,7 +14,11 @@ import { decryptToObject } from './utils/sops'
import { HydratedConfig } from './types'
const mockedOptions = defaultOptions
const mockedSchemaPath = 'some/path/to/schema.json'
const mockedOptions = {
...defaultOptions,
schemaPath: mockedSchemaPath,
}
const runtimeEnv = process.env.NODE_ENV || 'test'
const mockedConfigFile = {
filePath: './config/test.yaml',
......@@ -111,7 +115,7 @@ describe('load()', () => {
it('reads the schema file', () => {
load(mockedOptions)
expect(mockedReadSchemaFile).toHaveBeenCalledWith(defaultOptions.schemaPath)
expect(mockedReadSchemaFile).toHaveBeenCalledWith(mockedSchemaPath)
})
it('validates config against schema if schema was found', () => {
......@@ -126,7 +130,10 @@ describe('load()', () => {
it('generates types if options.types is not false', () => {
load(mockedOptions)
expect(generateTypeFromSchema).toHaveBeenCalledWith(mockedOptions)
expect(generateTypeFromSchema).toHaveBeenCalledWith(
mockedOptions.schemaPath,
mockedOptions.types
)
})
it('skips generating types if options.types is false', () => {
......@@ -138,20 +145,12 @@ describe('load()', () => {
expect(generateTypeFromSchema).toHaveBeenCalledTimes(0)
})
it('skips validating config if schema was not found', () => {
mockedReadSchemaFile.mockReturnValueOnce(null)
load(mockedOptions)
expect(validateJson).toHaveBeenCalledTimes(0)
})
it('skips generating types if schema was not found', () => {
it('throws when schemaPath is passed but no valid schema file can be read', () => {
mockedReadSchemaFile.mockReturnValueOnce(null)
load(mockedOptions)
expect(generateTypeFromSchema).toHaveBeenCalledTimes(0)
expect(() => load(mockedOptions)).toThrowError(
/Specified schema at \'\S*\' is not valid JSON or cannot be read/
)
})
it('returns the config', () => {
......
......@@ -12,7 +12,6 @@ import { Options } from './options'
export const load = (options: Options): HydratedConfig => {
const normalizedConfigPath = path.normalize(options.configPath)
const normalizedSchemaPath = path.normalize(options.schemaPath)
const runtimeEnv = process.env[options.runtimeEnvName]
if (R.isNil(runtimeEnv)) {
......@@ -28,13 +27,20 @@ export const load = (options: Options): HydratedConfig => {
const config = hydrateConfig(runtimeEnv, options)(decrypted)
const schemaFile = readSchemaFile(normalizedSchemaPath)
if (options.schemaPath) {
const normalizedSchemaPath = path.normalize(options.schemaPath)
const schemaFile = readSchemaFile(normalizedSchemaPath)
if (schemaFile === null) {
throw new Error(
`Specified schema at '${options.schemaPath}' is not valid JSON or cannot be read`
)
}
if (schemaFile !== null) {
validateJson(config, schemaFile.contents)
if (options.types !== false) {
generateTypeFromSchema(options)
generateTypeFromSchema(options.schemaPath, options.types)
}
}
......
......@@ -8,7 +8,7 @@ export interface Options {
types: TypeOptions | false
substitutionPattern: string
configPath: string
schemaPath: string
schemaPath: string | null
}
export const defaultOptions: Options = {
......@@ -19,5 +19,5 @@ export const defaultOptions: Options = {
},
substitutionPattern: '\\$\\{(\\w+)\\}',
configPath: 'config/',
schemaPath: 'config/schema.json',
schemaPath: null,
}
......@@ -62,8 +62,9 @@ export const optionsSchema = {
'config/schema.json',
'../schema.json',
'/app/config/schema.json',
null,
],
type: 'string',
type: ['string', 'null'],
},
},
}
import { defaultOptions, TypeOptions } from '../options'
const mockedOptions = defaultOptions
// define string here and not import from defaultOptions as defaultOptions.schemaPath can be null
const mockedSchemaPath = 'some/path/to/schema.json'
const mockedTypes = defaultOptions.types as TypeOptions
const mockedCompiledTypes = `
export interface TheTopLevelInterface {
name: string;
......@@ -64,22 +67,22 @@ describe('generateTypeFromSchema()', () => {
})
it('calls compileFromFile with a file path', async () => {
await generateTypeFromSchema(mockedOptions)
await generateTypeFromSchema(mockedSchemaPath, mockedTypes)
expect(mockedCompileFromFile).toHaveBeenCalledWith(mockedOptions.schemaPath)
expect(mockedCompileFromFile).toHaveBeenCalledWith(mockedSchemaPath)
})
it('reads the file at filePath', async () => {
await generateTypeFromSchema(mockedOptions)
await generateTypeFromSchema(mockedSchemaPath, mockedTypes)
expect(mockedFs.readFileSync).toHaveBeenCalledWith(mockedOptions.schemaPath)
expect(mockedFs.readFileSync).toHaveBeenCalledWith(mockedSchemaPath)
})
it('generates correct types', async () => {
const expectedTypes = `${mockedCompiledTypes}${expectedRootType}`
const typeOptions = mockedOptions.types as TypeOptions
const typeOptions = mockedTypes as TypeOptions
await generateTypeFromSchema(mockedOptions)
await generateTypeFromSchema(mockedSchemaPath, mockedTypes)
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
typeOptions.filePath,
......@@ -90,9 +93,9 @@ describe('generateTypeFromSchema()', () => {
it('throws when top-level schema definition does not have a title field', async () => {
mockedFs.readFileSync.mockReturnValueOnce(mockedSchemaStringWithoutTitle)
await expect(generateTypeFromSchema(mockedOptions)).rejects.toThrowError(
Error
)
await expect(
generateTypeFromSchema(mockedSchemaPath, mockedTypes)
).rejects.toThrowError(Error)
})
it('throws when top-level schema definition has invalid title field', async () => {
......@@ -100,6 +103,8 @@ describe('generateTypeFromSchema()', () => {
mockedSchemaStringWithInvalidTitle
)
await expect(generateTypeFromSchema(mockedOptions)).rejects.toThrow(Error)
await expect(
generateTypeFromSchema(mockedSchemaPath, mockedTypes)
).rejects.toThrow(Error)
})
})
......@@ -2,7 +2,7 @@ import { compileFromFile } from 'json-schema-to-typescript'
import fs from 'fs'
import R from 'ramda'
import { Options, TypeOptions } from '../options'
import { TypeOptions } from '../options'
// json-schema-to-typescript uses a `toSafeString(string)` function https://github.com/bcherny/json-schema-to-typescript/blob/f41945f19b68918e9c13885f345cb708e1d9898a/src/utils.ts#L163) to obtain a normalized string. This pascalCase mimics this functionality and should address most cases.
export const pascalCase = (input: string): string =>
......@@ -11,13 +11,10 @@ export const pascalCase = (input: string): string =>
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
export const generateTypeFromSchema = async ({
schemaPath,
types,
}: Options): Promise<void> => {
// When this function is called, we are sure that types !== false
types = types as TypeOptions
export const generateTypeFromSchema = async (
schemaPath: string,
types: TypeOptions
): Promise<void> => {
const baseTypes = await compileFromFile(schemaPath)
const schemaString = fs.readFileSync(schemaPath).toString()
......
......@@ -39,7 +39,7 @@ export const readConfigFileAtPath = (filePath: string): File =>
readConfigFile(path.dirname(filePath), path.basename(filePath))
export const readSchemaFile = (schemaPath: string): File | null => {
if (R.isNil(schemaPath) || !isJson(schemaPath)) {
if (!isJson(schemaPath)) {
return null
}
......
......@@ -19,6 +19,10 @@ export const validate = (
configPaths: string[],
{ schemaPath }: Options
): true => {
if (schemaPath === null) {
throw new Error('No schema was provided with options.schemaPath')
}
const normalizedSchemaPath = path.normalize(schemaPath)
const normalizedConfigPaths = configPaths.map(path.normalize)
......@@ -26,7 +30,7 @@ export const validate = (
const validateConfig = validateConfigAgainstSchema(schemaFile.contents)
if (R.isNil(schemaFile)) {
throw new Error('Could not find a schema for validation')
throw new Error(`Could not find a schema at path ${schemaPath}`)
}
findConfigFilesAtPaths(normalizedConfigPaths).forEach(validateConfig)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment