Commit 7049f5b6 authored by mohoff's avatar mohoff Committed by chapati

fix: --verbose flag properly working for decrypt/encrypt CLI commands

parent 33a0f370
......@@ -38,7 +38,7 @@
"lint:markdown": "markdownlint **/*.md --ignore node_modules --ignore CHANGELOG.md --ignore .gitlab",
"lint:yaml": "yamllint **/*.yaml **/*.yml --ignore=node_modules/**/*.yaml --ignore=node_modules/**/*.yml",
"release": "standard-version --message \"chore(release): %s [ci-release]\"",
"test": "yarn dev:importkey && jest --runInBand --verbose",
"test": "jest --verbose --color",
"todo": "leasot '**/*.ts' --ignore 'node_modules/**/*','lib/**/*','.git/**/*' || true",
"watch": "tsc --watch",
"clean": "rimraf build/",
......
jest.mock('./validate')
jest.mock('../spinner')
jest.mock('../../utils/sops')
import stdMocks from 'std-mocks'
import fs from 'fs'
import { validate } from './validate'
import {
startSpinner,
failSpinner,
succeedSpinner,
getVerbosityLevel,
VerbosityLevel,
} from '../spinner'
import { getSopsOptions, runSopsWithOptions } from '../../utils/sops'
const mockedRunSopsWithOptions = runSopsWithOptions as jest.MockedFunction<
typeof runSopsWithOptions
>
const mockedGetSopsOptions = getSopsOptions as jest.MockedFunction<
typeof getSopsOptions
>
const mockedValidate = validate as jest.MockedFunction<typeof validate>
// Expected output (decrypted config) without whitespace to allow for simple comparisons
const expectedDecryptedConfig = `
name: example-project
someField:
optionalField: 123
requiredField: crucial string
someArray:
- joe
- freeman
someSecret: cantSeeMe
`.replace(/\s/g, '')
const mockedStartSpinner = startSpinner as jest.MockedFunction<
typeof startSpinner
>
const mockedFailSpinner = failSpinner as jest.MockedFunction<typeof failSpinner>
const mockedSuceedSpinner = succeedSpinner as jest.MockedFunction<
typeof succeedSpinner
>
const mockedGetVerbosityLevel = getVerbosityLevel as jest.MockedFunction<
typeof getVerbosityLevel
>
mockedGetVerbosityLevel.mockReturnValue(VerbosityLevel.Verbose)
const mockedSopsOptions = ['--some', '--flags']
mockedGetSopsOptions.mockReturnValue(mockedSopsOptions)
const sopsError = new Error('some sops error')
import Decrypt from './decrypt'
const configPath = 'example/development.yaml'
const outputPath = 'example/development.decrypted.yaml'
const backupPath = 'example/development.backup.yaml'
const schemaPath = 'example/schema.json'
const mockedExit = jest.spyOn(process, 'exit').mockImplementation()
......@@ -69,40 +90,8 @@ describe('strong-config decrypt', () => {
})
describe('handles decryption', () => {
beforeAll(() => {
// Copy encrypted config file so we can restore it later
fs.copyFileSync(configPath, backupPath)
stdMocks.use()
})
beforeEach(() => {
jest.clearAllMocks()
stdMocks.flush()
})
afterEach(() => {
// Delete temporary output file (decrypted config)
try {
console.log('Unlinking')
fs.unlinkSync(outputPath)
} catch (err) {
console.log('No temporary output file found to delete')
}
// Restore backup config
fs.copyFileSync(backupPath, configPath)
})
afterAll(() => {
// Delete backup config
try {
fs.unlinkSync(backupPath)
} catch (err) {
console.log('Could not find backup config file to delete')
}
stdMocks.restore()
})
it('exits with code 0 when successful', async () => {
......@@ -111,36 +100,33 @@ describe('strong-config decrypt', () => {
expect(mockedExit).toHaveBeenCalledWith(0)
})
it('decrypts in-place when no output path is passed', async () => {
await Decrypt.run([configPath])
const decryptedConfigAsString = fs.readFileSync(configPath).toString()
expect(decryptedConfigAsString.replace(/\s/g, '')).toBe(
expectedDecryptedConfig
)
})
it('exits with code 1 when decryption fails', async () => {
mockedRunSopsWithOptions.mockImplementationOnce(() => {
throw sopsError
})
it('decrypts to unencrypted file when output path is passed', async () => {
await Decrypt.run([configPath, outputPath])
const decryptedConfigAsString = fs.readFileSync(outputPath).toString()
await Decrypt.run([configPath])
expect(decryptedConfigAsString.replace(/\s/g, '')).toBe(
expectedDecryptedConfig
)
expect(mockedExit).toHaveBeenCalledWith(1)
})
it('fails when config file does not exist', async () => {
await Decrypt.run(['non/existing/config.yaml'])
it('decrypts by using sops', async () => {
await Decrypt.run([configPath])
expect(mockedExit).toHaveBeenCalledWith(1)
expect(mockedRunSopsWithOptions).toHaveBeenCalledWith([
'--decrypt',
...mockedSopsOptions,
])
})
it('decrypts and validates when schema path is passed', async () => {
await Decrypt.run([configPath, '--schema-path', schemaPath])
expect(mockedValidate).toHaveBeenCalledTimes(1)
expect(mockedValidate).toHaveBeenCalledWith(
configPath,
schemaPath,
VerbosityLevel.Verbose
)
})
it('fails when no arguments are passed', async () => {
......@@ -148,34 +134,37 @@ describe('strong-config decrypt', () => {
/Missing 1 required arg/
)
})
})
it('fails when passed schema file does not exist', async () => {
// Note: This will still decrypt the config. Only after decryption, validation fails.
await Decrypt.run([
configPath,
'--schema-path',
'non/existing/schema.json',
])
describe('informs the user', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// TODO: Why doesn't it return exit code 1 here?
expect(mockedExit).toHaveBeenCalledWith(0)
it('informs user about the decryption process', async () => {
await Decrypt.run([configPath])
expect(mockedStartSpinner).toHaveBeenCalledWith('Decrypting...')
})
it('shows the decryption status', async () => {
await Decrypt.run([configPath, outputPath])
it('informs user about the decryption result', async () => {
await Decrypt.run([configPath])
expect(stdMocks.flush().stderr.join('')).toMatch('💪 Decrypted!')
expect(mockedSuceedSpinner).toHaveBeenCalledWith('Decrypted!')
})
it('shows an error when decryption fails ', async () => {
await Decrypt.run(['non/existing/config.yaml'])
it('informs user about decryption errors', async () => {
mockedRunSopsWithOptions.mockImplementationOnce(() => {
throw sopsError
})
expect(stdMocks.flush().stderr.join('')).toMatch(
/Failed to decrypt config file/
await Decrypt.run([configPath])
expect(mockedFailSpinner).toHaveBeenCalledWith(
'Failed to decrypt config file',
expect.any(Error),
VerbosityLevel.Verbose
)
})
// TODO: Investigate why 'Validated!' is not available in mocked stderr
test.todo('shows the validation status when schema path is passed')
})
})
......@@ -2,7 +2,12 @@
import { Command, flags } from '@oclif/command'
import { startSpinner, failSpinner, succeedSpinner } from '../spinner'
import {
startSpinner,
failSpinner,
succeedSpinner,
getVerbosityLevel,
} from '../spinner'
import { getSopsOptions, runSopsWithOptions } from '../../utils/sops'
import { validate } from './validate'
......@@ -59,7 +64,11 @@ export default class Decrypt extends Command {
// Validate unencrypted config after decryption
if (flags['schema-path']) {
validate(args['config_path'], flags['schema-path'], flags['verbose'])
validate(
args['config_path'],
flags['schema-path'],
getVerbosityLevel(flags.verbose)
)
}
process.exit(0)
......@@ -77,7 +86,11 @@ const decrypt = (
try {
runSopsWithOptions(sopsOptions)
} catch (error) {
failSpinner('Failed to decrypt config file', error, flags['isVerbose'])
failSpinner(
'Failed to decrypt config file',
error,
getVerbosityLevel(flags.verbose)
)
process.exit(1)
}
......
jest.mock('./validate')
jest.mock('../spinner')
jest.mock('../../utils/sops')
import stdMocks from 'std-mocks'
import fs from 'fs'
import { validate } from './validate'
import {
startSpinner,
failSpinner,
succeedSpinner,
getVerbosityLevel,
VerbosityLevel,
} from '../spinner'
import { getSopsOptions, runSopsWithOptions } from '../../utils/sops'
const mockedRunSopsWithOptions = runSopsWithOptions as jest.MockedFunction<
typeof runSopsWithOptions
>
const mockedGetSopsOptions = getSopsOptions as jest.MockedFunction<
typeof getSopsOptions
>
const mockedValidate = validate as jest.MockedFunction<typeof validate>
const mockedStartSpinner = startSpinner as jest.MockedFunction<
typeof startSpinner
>
const mockedFailSpinner = failSpinner as jest.MockedFunction<typeof failSpinner>
const mockedSuceedSpinner = succeedSpinner as jest.MockedFunction<
typeof succeedSpinner
>
const mockedGetVerbosityLevel = getVerbosityLevel as jest.MockedFunction<
typeof getVerbosityLevel
>
mockedGetVerbosityLevel.mockReturnValue(VerbosityLevel.Verbose)
const mockedSopsOptions = ['--some', '--flags']
mockedGetSopsOptions.mockReturnValue(mockedSopsOptions)
const sopsError = new Error('some sops error')
import Encrypt from './encrypt'
// This file is created in the beforeAll handler
const configPath = 'example/development.decrypted.yaml'
const outputPath = 'example/development.encrypted.yaml'
const backupPath = 'example/development.backup.yaml'
const schemaPath = 'example/schema.json'
const keyId = '2E9644A658379349EFB77E895351CE7FC0AC6E94' // example/pgp/example-keypair.pgp
......@@ -20,31 +52,6 @@ const requiredKeyFlags = ['-k', keyId, '-p', keyProvider]
const mockedExit = jest.spyOn(process, 'exit').mockImplementation()
const unencryptedConfig = `name: example-project
someField:
optionalField: 123
requiredField: crucial string
someArray:
- joe
- freeman
someSecret: cantSeeMe
`
const expectedEncryptedConfig = expect.arrayContaining([
'someField:',
'optionalField: 123',
'requiredField: crucial string',
'someArray:',
'- joe',
'- freeman',
expect.stringContaining('someSecret: ENC['),
'sops:',
'kms: []',
'gcp_kms: []',
'azure_kv: []',
'encrypted_suffix: Secret',
])
describe('strong-config encrypt', () => {
afterAll(() => {
mockedExit.mockRestore()
......@@ -93,43 +100,8 @@ describe('strong-config encrypt', () => {
})
describe('handles encryption', () => {
// Note: The PGP key required for encryption is imported in the test command in package.json
beforeAll(() => {
// Create unencrypted config file
fs.writeFileSync(configPath, unencryptedConfig)
// Copy unencrypted config file so we can restore it later
fs.copyFileSync(configPath, backupPath)
stdMocks.use()
})
beforeEach(() => {
jest.clearAllMocks()
stdMocks.flush()
})
afterEach(() => {
// Delete temporary output file (encrypted config)
try {
fs.unlinkSync(outputPath)
} catch (err) {
console.log('No temporary output file found to delete')
}
// Restore backup config
fs.copyFileSync(backupPath, configPath)
})
afterAll(() => {
// Delete unencrypted config and backup config
try {
fs.unlinkSync(configPath)
fs.unlinkSync(backupPath)
} catch (err) {
console.log('Could not find backup config file to delete')
}
stdMocks.restore()
})
it('exits with code 0 when successful', async () => {
......@@ -138,31 +110,23 @@ describe('strong-config encrypt', () => {
expect(mockedExit).toHaveBeenCalledWith(0)
})
it('encrypts in-place when no output path is passed', async () => {
await Encrypt.run([configPath, ...requiredKeyFlags])
const encryptedConfigAsString = fs.readFileSync(configPath).toString()
console.log(encryptedConfigAsString)
it('exits with code 1 when encryption fails', async () => {
mockedRunSopsWithOptions.mockImplementationOnce(() => {
throw sopsError
})
expect(encryptedConfigAsString.split('\n').map(s => s.trim())).toEqual(
expectedEncryptedConfig
)
})
it('encrypts to encrypted file when output path is passed', async () => {
await Encrypt.run([configPath, outputPath, ...requiredKeyFlags])
const encryptedConfigAsString = fs.readFileSync(outputPath).toString()
await Encrypt.run([configPath, ...requiredKeyFlags])
expect(encryptedConfigAsString.split('\n').map(s => s.trim())).toEqual(
expectedEncryptedConfig
)
expect(mockedExit).toHaveBeenCalledWith(1)
})
it('fails when config file does not exist', async () => {
await Encrypt.run(['non/existing/config.yaml', ...requiredKeyFlags])
it('encrypts by using sops', async () => {
await Encrypt.run([configPath, ...requiredKeyFlags])
expect(mockedExit).toHaveBeenCalledWith(1)
expect(mockedRunSopsWithOptions).toHaveBeenCalledWith([
'--encrypt',
...mockedSopsOptions,
])
})
it('encrypts and validates when schema path is passed', async () => {
......@@ -173,20 +137,11 @@ describe('strong-config encrypt', () => {
schemaPath,
])
expect(mockedValidate).toHaveBeenCalledTimes(1)
})
it('fails when passed schema file does not exist', async () => {
// Note: This will not encrypt the file as validation fails first
await Encrypt.run([
expect(mockedValidate).toHaveBeenCalledWith(
configPath,
...requiredKeyFlags,
'--schema-path',
'non/existing/schema.json',
])
// TODO: Why doesn't it return exit code 1 here?
expect(mockedExit).toHaveBeenCalledWith(0)
schemaPath,
VerbosityLevel.Verbose
)
})
it('fails when no arguments are passed', async () => {
......@@ -206,26 +161,37 @@ describe('strong-config encrypt', () => {
Encrypt.run([configPath, '-p', keyProvider])
).rejects.toThrowError(/--key-id= must also be provided/)
})
})
it('shows the encryption status', async () => {
await Encrypt.run([configPath, outputPath, ...requiredKeyFlags])
describe('informs the user', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('informs user about the encryption process', async () => {
await Encrypt.run([configPath, ...requiredKeyFlags])
expect(stdMocks.flush().stderr.join('')).toMatch('💪 Encrypted!')
expect(mockedStartSpinner).toHaveBeenCalledWith('Encrypting...')
})
it('shows an error when encryption fails ', async () => {
await Encrypt.run([
'non/existing/config.yaml',
outputPath,
...requiredKeyFlags,
])
it('informs user about the encryption result', async () => {
await Encrypt.run([configPath, ...requiredKeyFlags])
expect(stdMocks.flush().stderr.join('')).toMatch(
/Failed to encrypt config file/
)
expect(mockedSuceedSpinner).toHaveBeenCalledWith('Encrypted!')
})
// TODO: Investigate why 'Validated!' is not available in mocked stderr
test.todo('shows the validation status when schema path is passed')
it('informs user about encryption errors', async () => {
mockedRunSopsWithOptions.mockImplementationOnce(() => {
throw sopsError
})
await Encrypt.run([configPath, ...requiredKeyFlags])
expect(mockedFailSpinner).toHaveBeenCalledWith(
'Failed to encrypt config file',
expect.any(Error),
VerbosityLevel.Verbose
)
})
})
})
......@@ -2,7 +2,12 @@
import { Command, flags } from '@oclif/command'
import { startSpinner, failSpinner, succeedSpinner } from '../spinner'
import {
startSpinner,
failSpinner,
succeedSpinner,
getVerbosityLevel,
} from '../spinner'
import { getSopsOptions, runSopsWithOptions } from '../../utils/sops'
import { validate } from './validate'
......@@ -23,6 +28,7 @@ export default class Encrypt extends Command {
char: 'v',
description: 'print stack traces in case of errors',
default: false,
parse: input => (input ? VerbosityLevel.Verbose : VerbosityLevel.Default),
}),
'key-provider': flags.string({
char: 'p',
......@@ -86,7 +92,11 @@ export default class Encrypt extends Command {
// Validate unencrypted config prior to encryption
if (flags['schema-path']) {
validate(args['config_path'], flags['schema-path'], flags['verbose'])
validate(
args['config_path'],
flags['schema-path'],
getVerbosityLevel(flags.verbose)
)
}
encrypt(args, flags)
......@@ -106,7 +116,11 @@ const encrypt = (
try {
runSopsWithOptions(sopsOptions)
} catch (error) {
failSpinner('Failed to encrypt config file', error, flags['isVerbose'])
failSpinner(
'Failed to encrypt config file',
error,
getVerbosityLevel(flags.verbose)
)
process.exit(1)
}
......
jest.mock('../../validate')
jest.mock('../spinner')
import stdMocks from 'std-mocks'
import { validate as validateUtil } from '../../validate'
import {
startSpinner,
failSpinner,
succeedSpinner,
getVerbosityLevel,
VerbosityLevel,
} from '../spinner'
const mockedValidateUtil = validateUtil as jest.MockedFunction<
typeof validateUtil
>
const mockedStartSpinner = startSpinner as jest.MockedFunction<
typeof startSpinner
>
const mockedFailSpinner = failSpinner as jest.MockedFunction<typeof failSpinner>
const mockedSuceedSpinner = succeedSpinner as jest.MockedFunction<
typeof succeedSpinner
>
const mockedGetVerbosityLevel = getVerbosityLevel as jest.MockedFunction<
typeof getVerbosityLevel
>
mockedGetVerbosityLevel.mockReturnValue(VerbosityLevel.Verbose)
import Validate from './validate'
const configPath = 'example/development.yaml'
const schemaPath = 'example/schema.json'
const validationError = new Error('some validation error')
const mockedExit = jest.spyOn(process, 'exit').mockImplementation()
describe('strong-config validate', () => {
......@@ -56,17 +79,8 @@ describe('strong-config validate', () => {
})
describe('handles validation', () => {
beforeAll(() => {
stdMocks.use()
})
beforeEach(() => {
jest.clearAllMocks()
stdMocks.flush()
})
afterEach(() => {
stdMocks.restore()
})
it('exits with code 0 when successful', async () => {
......@@ -75,13 +89,52 @@ describe('strong-config validate', () => {
expect(mockedExit).toHaveBeenCalledWith(0)
})
it('exits with code 1 when validation fails', async () => {
mockedValidateUtil.mockImplementationOnce(() => {
throw validationError
})
await Validate.run([configPath, schemaPath])
expect(mockedExit).toHaveBeenCalledWith(1)
})
it('validates against schema', async () => {
await Validate.run([configPath, schemaPath])
expect(mockedValidateUtil).toHaveBeenCalledTimes(1)
})
})
describe('informs the user', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('informs user about the validation process', async () => {
await Validate.run([configPath, schemaPath])
expect(mockedStartSpinner).toHaveBeenCalledWith('Validating...')
})
it('informs user about the validation result', async () => {
await Validate.run([configPath, schemaPath])
expect(mockedSuceedSpinner).toHaveBeenCalledWith('Validated!')
})
// TODO: Investigate why this isn't writing to mocked stderr as it does with the decryption output
test.todo('shows the validation status')
it('informs user about validation errors', async () => {
mockedValidateUtil.mockImplementationOnce(() => {
throw validationError
})
await Validate.run([configPath, schemaPath])
expect(mockedFailSpinner).toHaveBeenCalledWith(
'Config validation against schema failed',
expect.any(Error),
VerbosityLevel.Verbose
)
})
})
})
......@@ -4,7 +4,13 @@ import { Command, flags } from '@oclif/command'
import { Options } from '../../options'
import { validate as validateConfig } from '../../validate'
import { startSpinner, failSpinner, succeedSpinner } from '../spinner'
import {
startSpinner,
failSpinner,
succeedSpinner,
getVerbosityLevel,
VerbosityLevel,
} from '../spinner'
export default class Validate extends Command {
static description = 'validate config files against a schema'
......@@ -46,7 +52,11 @@ export default class Validate extends Command {
async run() {
const { args, flags } = this.parse(Validate)
validate(args['config_path'], args['schema_path'], flags['verbose'])
validate(
args['config_path'],
args['schema_path'],
getVerbosityLevel(flags.verbose)
)
process.exit(0)
}
......@@ -55,14 +65,18 @@ export default class Validate extends Command {
export const validate = (
configPath: string,
schemaPath: string,
isVerbose: boolean
verbosityLevel: VerbosityLevel
): void => {
startSpinner('Validating...')
try {
validateConfig([configPath], { schemaPath } as Options)
} catch (error) {
failSpinner('Config validation against schema failed', error, isVerbose)
failSpinner(
'Config validation against schema failed',
error,
verbosityLevel
)
process.exit(1)
}
......
......@@ -7,14 +7,19 @@ export const startSpinner = (message: string): void => {
oraInstance = ora(message).start()
}
export enum VerbosityLevel {
Default = 0,
Verbose = 1,
}
<