diff --git a/commander/src/bootstrapping/commands/config/create.ts b/commander/src/bootstrapping/commands/config/create.ts index 15116b042fe..7b059970cbe 100644 --- a/commander/src/bootstrapping/commands/config/create.ts +++ b/commander/src/bootstrapping/commands/config/create.ts @@ -20,7 +20,7 @@ import { join, resolve } from 'path'; import * as inquirer from 'inquirer'; import { isHexString } from '@liskhq/lisk-validator'; import { defaultConfig } from '../../../utils/config'; -import { OWNER_READ_WRITE } from '../../../constants'; +import { handleOutputFlag } from '../../../utils/output'; export class CreateCommand extends Command { static description = 'Creates network configuration file.'; @@ -81,17 +81,14 @@ export class CreateCommand extends Command { if (!userResponse.confirm) { this.error('Operation cancelled, config file already present at the desired location'); } else { - fs.writeJSONSync(resolve(configPath, 'config.json'), defaultConfig, { - spaces: '\t', - mode: OWNER_READ_WRITE, - }); + const res = await handleOutputFlag(configPath, defaultConfig, 'config'); + this.log(res); } } else { fs.mkdirSync(configPath, { recursive: true }); - fs.writeJSONSync(resolve(configPath, 'config.json'), defaultConfig, { - spaces: '\t', - mode: OWNER_READ_WRITE, - }); + + const res = await handleOutputFlag(configPath, defaultConfig, 'config'); + this.log(res); } } } diff --git a/commander/src/bootstrapping/commands/generator/export.ts b/commander/src/bootstrapping/commands/generator/export.ts index aa326d65b01..30526758f69 100644 --- a/commander/src/bootstrapping/commands/generator/export.ts +++ b/commander/src/bootstrapping/commands/generator/export.ts @@ -14,11 +14,10 @@ */ import { encrypt } from '@liskhq/lisk-cryptography'; -import * as fs from 'fs-extra'; import * as path from 'path'; import { flagsWithParser } from '../../../utils/flags'; import { BaseIPCClientCommand } from '../base_ipc_client'; -import { OWNER_READ_WRITE } from '../../../constants'; +import { handleOutputFlag } from '../../../utils/output'; interface EncryptedMessageObject { readonly version: string; @@ -86,11 +85,6 @@ export abstract class ExportCommand extends BaseIPCClientCommand { this.error('APIClient is not initialized.'); } - if (flags.output) { - const { dir } = path.parse(flags.output); - fs.ensureDirSync(dir); - } - const allKeys = await this._client.invoke('generator_getAllKeys'); const statusResponse = await this._client.invoke('generator_getStatus'); @@ -120,7 +114,7 @@ export abstract class ExportCommand extends BaseIPCClientCommand { }; const filePath = flags.output ? flags.output : path.join(process.cwd(), 'generator_info.json'); - fs.writeJSONSync(filePath, output, { spaces: ' ', mode: OWNER_READ_WRITE }); - this.log(`Generator info is exported to ${filePath}`); + const res = await handleOutputFlag(filePath, output, 'generator_info'); + this.log(res); } } diff --git a/commander/src/bootstrapping/commands/hash-onion.ts b/commander/src/bootstrapping/commands/hash-onion.ts index 390442f5d19..f76079c8f51 100644 --- a/commander/src/bootstrapping/commands/hash-onion.ts +++ b/commander/src/bootstrapping/commands/hash-onion.ts @@ -14,12 +14,10 @@ */ import { Command, Flags as flagParser } from '@oclif/core'; -import * as fs from 'fs-extra'; -import * as path from 'path'; import * as cryptography from '@liskhq/lisk-cryptography'; import * as validator from '@liskhq/lisk-validator'; import { flagsWithParser } from '../../utils/flags'; -import { OWNER_READ_WRITE } from '../../constants'; +import { handleOutputFlag } from '../../utils/output'; export class HashOnionCommand extends Command { static description = 'Create hash onions to be used by the forger.'; @@ -60,11 +58,6 @@ export class HashOnionCommand extends Command { throw new Error('Count flag must be an integer and greater than 0.'); } - if (output) { - const { dir } = path.parse(output); - fs.ensureDirSync(dir); - } - const seed = cryptography.utils.generateHashOnionSeed(); const hashBuffers = cryptography.utils.hashOnion(seed, count, distance); @@ -73,11 +66,8 @@ export class HashOnionCommand extends Command { const result = { count, distance, hashes }; if (output) { - if (pretty) { - fs.writeJSONSync(output, result, { spaces: ' ', mode: OWNER_READ_WRITE }); - } else { - fs.writeJSONSync(output, result, { mode: OWNER_READ_WRITE }); - } + const res = await handleOutputFlag(output, result, 'hash-onion'); + this.log(res); } else { this.printJSON(result, pretty); } diff --git a/commander/src/bootstrapping/commands/keys/create.ts b/commander/src/bootstrapping/commands/keys/create.ts index e47cd8dbd45..b3925cd1660 100644 --- a/commander/src/bootstrapping/commands/keys/create.ts +++ b/commander/src/bootstrapping/commands/keys/create.ts @@ -16,11 +16,10 @@ import { codec } from '@liskhq/lisk-codec'; import { bls, address as addressUtil, ed, encrypt, legacy } from '@liskhq/lisk-cryptography'; import { Command, Flags as flagParser } from '@oclif/core'; -import * as fs from 'fs-extra'; -import * as path from 'path'; import { flagsWithParser } from '../../../utils/flags'; import { getPassphraseFromPrompt, getPasswordFromPrompt } from '../../../utils/reader'; -import { OWNER_READ_WRITE, plainGeneratorKeysSchema } from '../../../constants'; +import { plainGeneratorKeysSchema } from '../../../constants'; +import { handleOutputFlag } from '../../../utils/output'; export class CreateCommand extends Command { static description = 'Return keys corresponding to the given passphrase.'; @@ -37,7 +36,10 @@ export class CreateCommand extends Command { ]; static flags = { - output: flagsWithParser.output, + output: flagParser.string({ + char: 'o', + description: 'The output directory. Default will set to current working directory.', + }), passphrase: flagsWithParser.passphrase, 'no-encrypt': flagParser.boolean({ char: 'n', @@ -79,10 +81,6 @@ export class CreateCommand extends Command { }, } = await this.parse(CreateCommand); - if (output) { - const { dir } = path.parse(output); - fs.ensureDirSync(dir); - } const passphrase = passphraseSource ?? (await getPassphraseFromPrompt('passphrase', true)); let password = ''; if (!noEncrypt) { @@ -156,7 +154,8 @@ export class CreateCommand extends Command { } if (output) { - fs.writeJSONSync(output, { keys }, { spaces: ' ', mode: OWNER_READ_WRITE }); + const res = await handleOutputFlag(output, { keys }, 'keys'); + this.log(res); } else { this.log(JSON.stringify({ keys }, undefined, ' ')); } diff --git a/commander/src/bootstrapping/commands/keys/export.ts b/commander/src/bootstrapping/commands/keys/export.ts index 2324b9fbbd6..911ab02ae40 100644 --- a/commander/src/bootstrapping/commands/keys/export.ts +++ b/commander/src/bootstrapping/commands/keys/export.ts @@ -13,11 +13,9 @@ * */ import { encrypt } from '@liskhq/lisk-cryptography'; -import * as fs from 'fs-extra'; -import * as path from 'path'; import { flagsWithParser } from '../../../utils/flags'; import { BaseIPCClientCommand } from '../base_ipc_client'; -import { OWNER_READ_WRITE } from '../../../constants'; +import { handleOutputFlag } from '../../../utils/output'; interface EncryptedMessageObject { readonly version: string; @@ -66,22 +64,19 @@ export abstract class ExportCommand extends BaseIPCClientCommand { ...BaseIPCClientCommand.flags, output: { ...flagsWithParser.output, - required: true, }, }; async run(): Promise { const { flags } = await this.parse(ExportCommand); + if (!this._client) { this.error('APIClient is not initialized.'); } - const { dir } = path.parse(flags.output as string); - fs.ensureDirSync(dir); - const response = await this._client.invoke('generator_getAllKeys'); - const keys = response.keys.map(k => { + const keys = response?.keys.map(k => { if (k.type === 'encrypted') { return { address: k.address, @@ -94,6 +89,9 @@ export abstract class ExportCommand extends BaseIPCClientCommand { }; }); - fs.writeJSONSync(flags.output as string, { keys }, { spaces: ' ', mode: OWNER_READ_WRITE }); + if (flags.output) { + const res = await handleOutputFlag(flags.output, { keys }, 'keys'); + this.log(res); + } } } diff --git a/commander/src/bootstrapping/commands/passphrase/create.ts b/commander/src/bootstrapping/commands/passphrase/create.ts index 7b299a9aba6..06fe100739f 100644 --- a/commander/src/bootstrapping/commands/passphrase/create.ts +++ b/commander/src/bootstrapping/commands/passphrase/create.ts @@ -14,17 +14,17 @@ */ import { Mnemonic } from '@liskhq/lisk-passphrase'; -import { Command } from '@oclif/core'; -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { flagsWithParser } from '../../../utils/flags'; -import { OWNER_READ_WRITE } from '../../../constants'; +import { Command, Flags as flagParser } from '@oclif/core'; +import { handleOutputFlag } from '../../../utils/output'; export class CreateCommand extends Command { static description = 'Returns a randomly generated 24 words mnemonic passphrase.'; static examples = ['passphrase:create', 'passphrase:create --output /mypath/passphrase.json']; static flags = { - output: flagsWithParser.output, + output: flagParser.string({ + char: 'o', + description: 'The output directory. Default will set to current working directory.', + }), }; async run(): Promise { @@ -32,15 +32,11 @@ export class CreateCommand extends Command { flags: { output }, } = await this.parse(CreateCommand); - if (output) { - const { dir } = path.parse(output); - fs.ensureDirSync(dir); - } - const passphrase = Mnemonic.generateMnemonic(256); if (output) { - fs.writeJSONSync(output, { passphrase }, { spaces: ' ', mode: OWNER_READ_WRITE }); + const res = await handleOutputFlag(output, { passphrase }, 'passphrase'); + this.log(res); } else { this.log(JSON.stringify({ passphrase }, undefined, ' ')); } diff --git a/commander/src/bootstrapping/commands/passphrase/encrypt.ts b/commander/src/bootstrapping/commands/passphrase/encrypt.ts index 7414f64cd46..832e8d1c5e3 100644 --- a/commander/src/bootstrapping/commands/passphrase/encrypt.ts +++ b/commander/src/bootstrapping/commands/passphrase/encrypt.ts @@ -14,12 +14,10 @@ */ import { Command, Flags as flagParser } from '@oclif/core'; -import * as fs from 'fs-extra'; -import * as path from 'path'; import { encryptPassphrase } from '../../../utils/commons'; import { flagsWithParser } from '../../../utils/flags'; import { getPassphraseFromPrompt, getPasswordFromPrompt } from '../../../utils/reader'; -import { OWNER_READ_WRITE } from '../../../constants'; +import { handleOutputFlag } from '../../../utils/output'; const outputPublicKeyOptionDescription = 'Includes the public key in the output. This option is provided for the convenience of node operators.'; @@ -41,7 +39,10 @@ export class EncryptCommand extends Command { 'output-public-key': flagParser.boolean({ description: outputPublicKeyOptionDescription, }), - output: flagsWithParser.output, + output: flagParser.string({ + char: 'o', + description: 'The output directory. Default will set to current working directory.', + }), }; async run(): Promise { @@ -54,17 +55,13 @@ export class EncryptCommand extends Command { }, } = await this.parse(EncryptCommand); - if (output) { - const { dir } = path.parse(output); - fs.ensureDirSync(dir); - } - const passphrase = passphraseSource ?? (await getPassphraseFromPrompt('passphrase', true)); const password = passwordSource ?? (await getPasswordFromPrompt('password', true)); const result = await encryptPassphrase(passphrase, password, outputPublicKey); if (output) { - fs.writeJSONSync(output, result, { spaces: ' ', mode: OWNER_READ_WRITE }); + const res = await handleOutputFlag(output, result, 'passphrase'); + this.log(res); } else { this.log(JSON.stringify(result, undefined, ' ')); } diff --git a/commander/src/utils/flags.ts b/commander/src/utils/flags.ts index 8d4f5207728..6734e599ca9 100644 --- a/commander/src/utils/flags.ts +++ b/commander/src/utils/flags.ts @@ -155,7 +155,10 @@ export const flagsWithParser = { }), pretty: flagParser.boolean(flags.pretty), passphrase: flagParser.string(flags.passphrase), - output: flagParser.string(flags.output), + output: flagParser.string({ + ...flags.output, + default: process.cwd(), + }), password: flagParser.string(flags.password), offline: flagParser.boolean({ ...flags.offline, diff --git a/commander/src/utils/output.ts b/commander/src/utils/output.ts new file mode 100644 index 00000000000..d7185db38de --- /dev/null +++ b/commander/src/utils/output.ts @@ -0,0 +1,91 @@ +/* + * LiskHQ/lisk-commander + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + * + */ + +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { homedir } from 'os'; +import { OWNER_READ_WRITE } from '../constants'; + +interface OutputOptions { + outputPath?: string; + filename?: string; +} + +async function getDefaultFilename(namespace: string): Promise { + return `${namespace}.json`; +} + +function resolvePath(filePath: string): string { + if (filePath.startsWith('~')) { + return path.join(homedir(), filePath.slice(1)); + } + + return path.resolve(filePath); +} + +async function handleOutput(options: OutputOptions, namespace: string): Promise { + const outputPath = options.outputPath ?? process.cwd(); + const filename = options.filename ?? (await getDefaultFilename(namespace)); + + const resolvedPath = resolvePath(outputPath); + const outputPathWithFilename = path.join(resolvedPath, filename); + + await fs.mkdir(resolvedPath, { recursive: true }); + + return outputPathWithFilename; +} + +export async function handleOutputFlag( + outputPath: string, + data: object, + namespace: string, + filename?: string, +): Promise { + // if output path has an extension, then it is a file and write to current directory + if (path.extname(outputPath)) { + const resolvedPath = resolvePath(outputPath); + const resolvedPathWithFilename = path.join(resolvedPath, filename ?? ''); + + try { + fs.writeJSONSync(resolvedPathWithFilename, data, { + spaces: ' ', + mode: OWNER_READ_WRITE, + }); + + return `Successfully written data to ${resolvedPathWithFilename}`; + } catch (error) { + throw new Error(`Error writing data to ${resolvedPathWithFilename}: ${error as string}`); + } + } + + const options: OutputOptions = { + outputPath, + filename, + }; + + const outputFilePath = await handleOutput(options, namespace); + + try { + fs.writeJSONSync(outputFilePath, data, { + spaces: ' ', + mode: OWNER_READ_WRITE, + }); + + return `Successfully written data to ${outputFilePath}`; + } catch (error) { + throw new Error(`Error writing data to ${outputFilePath}: ${error as string}`); + } +} diff --git a/commander/test/bootstrapping/commands/generator/export.spec.ts b/commander/test/bootstrapping/commands/generator/export.spec.ts index 39d2dfa6e9b..322c71a0209 100644 --- a/commander/test/bootstrapping/commands/generator/export.spec.ts +++ b/commander/test/bootstrapping/commands/generator/export.spec.ts @@ -17,12 +17,11 @@ import { ed, bls, encrypt, utils } from '@liskhq/lisk-cryptography'; import * as apiClient from '@liskhq/lisk-api-client'; import { when } from 'jest-when'; import * as fs from 'fs-extra'; -import path = require('path'); import * as appUtils from '../../../../src/utils/application'; import { ExportCommand } from '../../../../src/bootstrapping/commands/generator/export'; import { getConfig } from '../../../helpers/config'; import { Awaited } from '../../../types'; -import { OWNER_READ_WRITE } from '../../../../src/constants'; +import * as outputUtils from '../../../../src/utils/output'; describe('generator:export', () => { const defaultPassword = 'elephant tree paris dragon chair galaxy'; @@ -117,17 +116,19 @@ describe('generator:export', () => { generatorInfo: info, }; + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/generator_info.json'), + ); + await ExportCommand.run([], config); - expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); - expect(fs.writeJSONSync).toHaveBeenCalledWith( - path.join(process.cwd(), 'generator_info.json'), - expect.any(Object), - { spaces: ' ', mode: OWNER_READ_WRITE }, - ); - expect(fs.ensureDirSync).toHaveBeenCalledTimes(0); - expect(stdout[0]).toContain( - `Generator info is exported to ${path.join(process.cwd(), 'generator_info.json')}`, + expect(outputUtils.handleOutputFlag).toHaveBeenCalledTimes(1); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + process.cwd(), + fileData, + 'generator_info', ); }); }); @@ -144,14 +145,21 @@ describe('generator:export', () => { keys: [{ address: allKeysPlain[0].address, plain: allKeysPlain[0].data }], generatorInfo: info, }; + + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/generator_info.json'), + ); + await ExportCommand.run(['--output=/my/path/info.json'], config); - expect(fs.ensureDirSync).toHaveBeenCalledTimes(1); - expect(fs.ensureDirSync).toHaveBeenCalledWith('/my/path'); - expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); - expect(fs.writeJSONSync).toHaveBeenCalledWith('/my/path/info.json', fileData, { - spaces: ' ', - mode: OWNER_READ_WRITE, - }); + + expect(outputUtils.handleOutputFlag).toHaveBeenCalledTimes(1); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/my/path/info.json', + fileData, + 'generator_info', + ); }); }); @@ -167,14 +175,21 @@ describe('generator:export', () => { keys: [{ address: allKeysEncrypted[0].address, encrypted: allKeysEncrypted[0].data }], generatorInfo: info, }; + + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/generator_info.json'), + ); + await ExportCommand.run(['--output=/my/path/info.json', '--data-path=/my/app/'], config); - expect(fs.ensureDirSync).toHaveBeenCalledTimes(1); - expect(fs.ensureDirSync).toHaveBeenCalledWith('/my/path'); - expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); - expect(fs.writeJSONSync).toHaveBeenCalledWith('/my/path/info.json', fileData, { - spaces: ' ', - mode: OWNER_READ_WRITE, - }); + + expect(outputUtils.handleOutputFlag).toHaveBeenCalledTimes(1); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/my/path/info.json', + fileData, + 'generator_info', + ); }); }); }); diff --git a/commander/test/bootstrapping/commands/hash-onion.spec.ts b/commander/test/bootstrapping/commands/hash-onion.spec.ts index 97eaec9e7a8..8ecbb893c7d 100644 --- a/commander/test/bootstrapping/commands/hash-onion.spec.ts +++ b/commander/test/bootstrapping/commands/hash-onion.spec.ts @@ -18,7 +18,7 @@ import * as cryptography from '@liskhq/lisk-cryptography'; import { HashOnionCommand } from '../../../src/bootstrapping/commands/hash-onion'; import { getConfig } from '../../helpers/config'; import { Awaited } from '../../types'; -import { OWNER_READ_WRITE } from '../../../src/constants'; +import * as outputUtils from '../../../src/utils/output'; describe('hash-onion command', () => { let stdout: string[]; @@ -53,26 +53,41 @@ describe('hash-onion command', () => { describe('hash-onion --count=1000 --distance=200 --output=./test/sample.json', () => { it('should write to file', async () => { + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/sample.json'), + ); + await HashOnionCommand.run( ['--count=1000', '--distance=200', '--output=./test/sample.json'], config, ); - expect(fs.ensureDirSync).toHaveBeenCalledWith('./test'); - expect(fs.writeJSONSync).toHaveBeenCalledWith('./test/sample.json', expect.anything(), { - mode: OWNER_READ_WRITE, - }); + + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + './test/sample.json', + expect.anything(), + 'hash-onion', + ); }); it('should write to file in pretty format', async () => { + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/sample.json'), + ); + await HashOnionCommand.run( ['--count=1000', '--distance=200', '--pretty', '--output=./test/sample.json'], config, ); - expect(fs.ensureDirSync).toHaveBeenCalledWith('./test'); - expect(fs.writeJSONSync).toHaveBeenCalledWith('./test/sample.json', expect.anything(), { - spaces: ' ', - mode: OWNER_READ_WRITE, - }); + + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + './test/sample.json', + expect.anything(), + 'hash-onion', + ); }); }); diff --git a/commander/test/bootstrapping/commands/keys/create.spec.ts b/commander/test/bootstrapping/commands/keys/create.spec.ts index 3b1bd4c6e6f..9cff75e1be2 100644 --- a/commander/test/bootstrapping/commands/keys/create.spec.ts +++ b/commander/test/bootstrapping/commands/keys/create.spec.ts @@ -20,7 +20,7 @@ import * as readerUtils from '../../../../src/utils/reader'; import { CreateCommand } from '../../../../src/bootstrapping/commands/keys/create'; import { getConfig } from '../../../helpers/config'; import { Awaited } from '../../../types'; -import { OWNER_READ_WRITE } from '../../../../src/constants'; +import * as outputUtils from '../../../../src/utils/output'; jest.mock('@liskhq/lisk-cryptography', () => ({ ...jest.requireActual('@liskhq/lisk-cryptography'), @@ -321,6 +321,12 @@ describe('keys:create command', () => { blsKeyPath2, ); + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/keys.json'), + ); + await CreateCommand.run( [ '--passphrase=enemy pill squeeze gold spoil aisle awake thumb congress false box wagon', @@ -367,11 +373,11 @@ describe('keys:create command', () => { ); expect(cryptography.bls.getPublicKeyFromPrivateKey).toHaveBeenCalledWith(blsPrivateKey2); - expect(fs.ensureDirSync).toHaveBeenCalledWith('/tmp'); - expect(fs.writeJSONSync).toHaveBeenCalledWith('/tmp/keys.json', expect.anything(), { - spaces: ' ', - mode: OWNER_READ_WRITE, - }); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/tmp/keys.json', + expect.anything(), + 'keys', + ); }); }); }); diff --git a/commander/test/bootstrapping/commands/keys/export.spec.ts b/commander/test/bootstrapping/commands/keys/export.spec.ts index fc50cf98931..37572887128 100644 --- a/commander/test/bootstrapping/commands/keys/export.spec.ts +++ b/commander/test/bootstrapping/commands/keys/export.spec.ts @@ -24,6 +24,7 @@ import { ExportCommand } from '../../../../src/bootstrapping/commands/keys/expor import { getConfig } from '../../../helpers/config'; import { Awaited } from '../../../types'; import { OWNER_READ_WRITE, plainGeneratorKeysSchema } from '../../../../src/constants'; +import * as outputUtils from '../../../../src/utils/output'; describe('keys:export', () => { const defaultPassword = 'elephant tree paris dragon chair galaxy'; @@ -59,7 +60,6 @@ describe('keys:export', () => { jest.spyOn(process.stdout, 'write').mockImplementation(val => stdout.push(val as string) > -1); jest.spyOn(process.stderr, 'write').mockImplementation(val => stderr.push(val as string) > -1); jest.spyOn(appUtils, 'isApplicationRunning').mockReturnValue(true); - jest.spyOn(fs, 'ensureDirSync').mockReturnValue(); jest.spyOn(fs, 'writeJSONSync').mockReturnValue(); invokeMock = jest.fn(); jest.spyOn(apiClient, 'createIPCClient').mockResolvedValue({ @@ -68,9 +68,79 @@ describe('keys:export', () => { } as never); }); - describe('when exporting without a file path flag', () => { - it('should throw an error', async () => { - await expect(ExportCommand.run([], config)).rejects.toThrow('Missing required flag output'); + describe('run', () => { + describe('when calling the handleOutputFlag', () => { + it('should call handleOutputFlag with the correct params', async () => { + when(invokeMock) + .calledWith('generator_getAllKeys') + .mockResolvedValue({ + keys: [ + { + address, + type: 'plain', + data: defaultKeysJSON, + }, + ], + }); + + fileData = { + keys: [ + { + address, + plain: defaultKeysJSON, + }, + ], + }; + + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/keys.json'), + ); + + await ExportCommand.run(['--output=/my/path/keys.json'], config); + + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/my/path/keys.json', + fileData, + 'keys', + ); + }); + + it('should throw an error when handleOutputFlag errs', async () => { + when(invokeMock) + .calledWith('generator_getAllKeys') + .mockResolvedValue({ + keys: [ + { + address, + type: 'plain', + data: defaultKeysJSON, + }, + ], + }); + + fileData = { + keys: [ + { + address, + plain: defaultKeysJSON, + }, + ], + }; + + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.reject( + new Error('Error writing data to /my/path/keys.json: Error: write error'), + ), + ); + + await expect(ExportCommand.run(['--output=/my/path/keys.json'], config)).rejects.toThrow( + 'Error writing data to /my/path/keys.json: Error: write error', + ); + }); }); }); @@ -80,22 +150,26 @@ describe('keys:export', () => { 'passphrase', "m/25519'/134'/0'/0'", ); + const blsPrivateKey = await bls.getPrivateKeyFromPhraseAndPath( 'passphrase', 'm/12381/134/0/0', ); + defaultKeys = { generatorKey: ed.getPublicKeyFromPrivateKey(generatorPrivateKey), generatorPrivateKey, blsPrivateKey, blsKey: bls.getPublicKeyFromPrivateKey(blsPrivateKey), }; + defaultKeysJSON = { generatorKey: defaultKeys.generatorKey.toString('hex'), generatorPrivateKey: defaultKeys.generatorPrivateKey.toString('hex'), blsPrivateKey: defaultKeys.blsPrivateKey.toString('hex'), blsKey: defaultKeys.blsKey.toString('hex'), }; + defaultEncryptedKeys = { address: Buffer.from('9cabee3d27426676b852ce6b804cb2fdff7cd0b5', 'hex'), type: 'encrypted', @@ -124,6 +198,7 @@ describe('keys:export', () => { }, ], }); + fileData = { keys: [ { @@ -132,9 +207,9 @@ describe('keys:export', () => { }, ], }; + await ExportCommand.run(['--output=/my/path/keys.json'], config); - expect(fs.ensureDirSync).toHaveBeenCalledTimes(1); - expect(fs.ensureDirSync).toHaveBeenCalledWith('/my/path'); + expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); expect(fs.writeJSONSync).toHaveBeenCalledWith('/my/path/keys.json', fileData, { spaces: ' ', @@ -156,6 +231,7 @@ describe('keys:export', () => { }, ], }); + fileData = { keys: [ { @@ -164,9 +240,9 @@ describe('keys:export', () => { }, ], }; + await ExportCommand.run(['--output=/my/path/keys.json', '--data-path=/my/app/'], config); - expect(fs.ensureDirSync).toHaveBeenCalledTimes(1); - expect(fs.ensureDirSync).toHaveBeenCalledWith('/my/path'); + expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); expect(fs.writeJSONSync).toHaveBeenCalledWith('/my/path/keys.json', fileData, { spaces: ' ', diff --git a/commander/test/bootstrapping/commands/passphrase/create.spec.ts b/commander/test/bootstrapping/commands/passphrase/create.spec.ts index 133b7ead49a..6e09772ad05 100644 --- a/commander/test/bootstrapping/commands/passphrase/create.spec.ts +++ b/commander/test/bootstrapping/commands/passphrase/create.spec.ts @@ -19,7 +19,7 @@ import * as fs from 'fs-extra'; import { CreateCommand } from '../../../../src/bootstrapping/commands/passphrase/create'; import { getConfig } from '../../../helpers/config'; import { Awaited } from '../../../types'; -import { OWNER_READ_WRITE } from '../../../../src/constants'; +import * as outputUtils from '../../../../src/utils/output'; describe('passphrase:create command', () => { const consoleWarnSpy = jest.spyOn(console, 'warn'); @@ -53,13 +53,20 @@ describe('passphrase:create command', () => { describe('keys:create --output /tmp/passphrase.json', () => { it('should create valid passphrase', async () => { + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /my/path/passphrase.json'), + ); + await CreateCommand.run(['--output=/tmp/passphrase.json'], config); - expect(fs.ensureDirSync).toHaveBeenCalledWith('/tmp'); - expect(fs.writeJSONSync).toHaveBeenCalledWith('/tmp/passphrase.json', expect.anything(), { - spaces: ' ', - mode: OWNER_READ_WRITE, - }); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledTimes(1); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/tmp/passphrase.json', + { passphrase: expect.any(String) }, + 'passphrase', + ); }); }); }); diff --git a/commander/test/bootstrapping/commands/passphrase/encrypt.spec.ts b/commander/test/bootstrapping/commands/passphrase/encrypt.spec.ts index 88c65e66289..b8bc8bcc47c 100644 --- a/commander/test/bootstrapping/commands/passphrase/encrypt.spec.ts +++ b/commander/test/bootstrapping/commands/passphrase/encrypt.spec.ts @@ -18,7 +18,7 @@ import * as readerUtils from '../../../../src/utils/reader'; import { EncryptCommand } from '../../../../src/bootstrapping/commands/passphrase/encrypt'; import { getConfig } from '../../../helpers/config'; import { Awaited } from '../../../types'; -import { OWNER_READ_WRITE } from '../../../../src/constants'; +import * as outputUtils from '../../../../src/utils/output'; jest.mock('@liskhq/lisk-cryptography', () => ({ ...jest.requireActual('@liskhq/lisk-cryptography'), @@ -92,64 +92,78 @@ describe('passphrase:encrypt', () => { describe('passphrase:encrypt --output-public-key --output=/mypath/keys.json', () => { it('should encrypt passphrase and output public key', async () => { - await EncryptCommand.run(['--output-public-key', '--output=/mypath/keys.json'], config); + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /mypath/passphrase.json'), + ); + + await EncryptCommand.run(['--output-public-key', '--output=/mypath/passphrase.json'], config); + expect(cryptography.encrypt.encryptMessageWithPassword).toHaveBeenCalledWith( defaultInputs.passphrase, defaultInputs.password, ); expect(readerUtils.getPassphraseFromPrompt).toHaveBeenCalledWith('passphrase', true); expect(readerUtils.getPasswordFromPrompt).toHaveBeenCalledWith('password', true); - expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); - expect(fs.writeJSONSync).toHaveBeenCalledWith( - '/mypath/keys.json', + expect(outputUtils.handleOutputFlag).toHaveBeenCalledTimes(1); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/mypath/passphrase.json', { encryptedPassphrase: encryptedPassphraseObject, publicKey: defaultKeys.publicKey.toString('hex'), }, - { - spaces: ' ', - mode: OWNER_READ_WRITE, - }, + 'passphrase', ); }); }); describe('passphrase:encrypt --passphrase="enemy pill squeeze gold spoil aisle awake thumb congress false box wagon" --output=/mypath/keys.json', () => { it('should encrypt passphrase from passphrase flag and stdout password', async () => { + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /mypath/passphrase.json'), + ); + await EncryptCommand.run( [ '--passphrase=enemy pill squeeze gold spoil aisle awake thumb congress false box wagon', - '--output=/mypath/keys.json', + '--output=/mypath/passphrase.json', ], config, ); + expect(cryptography.encrypt.encryptMessageWithPassword).toHaveBeenCalledWith( defaultInputs.passphrase, defaultInputs.password, ); expect(readerUtils.getPassphraseFromPrompt).not.toHaveBeenCalledWith('passphrase', true); expect(readerUtils.getPasswordFromPrompt).toHaveBeenCalledWith('password', true); - expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); - expect(fs.writeJSONSync).toHaveBeenCalledWith( - '/mypath/keys.json', + expect(outputUtils.handleOutputFlag).toHaveBeenCalledTimes(1); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/mypath/passphrase.json', { encryptedPassphrase: encryptedPassphraseObject, }, - { - spaces: ' ', - mode: OWNER_READ_WRITE, - }, + 'passphrase', ); }); }); describe('passphrase:encrypt --passphrase="enemy pill squeeze gold spoil aisle awake thumb congress false box wagon" --password=LbYpLpV9Wpec6ux8 --output=/mypath/keys.json', () => { it('should encrypt passphrase from passphrase and password flags', async () => { + jest + .spyOn(outputUtils, 'handleOutputFlag') + .mockImplementation(async () => + Promise.resolve('Successfully written data to /mypath/passphrase.json'), + ); + await EncryptCommand.run( [ '--passphrase=enemy pill squeeze gold spoil aisle awake thumb congress false box wagon', '--password=LbYpLpV9Wpec6ux8', - '--output=/mypath/keys.json', + '--output=/mypath/passphrase.json', ], config, ); @@ -159,16 +173,13 @@ describe('passphrase:encrypt', () => { ); expect(readerUtils.getPassphraseFromPrompt).not.toHaveBeenCalledWith('passphrase', true); expect(readerUtils.getPasswordFromPrompt).not.toHaveBeenCalledWith('password', true); - expect(fs.writeJSONSync).toHaveBeenCalledTimes(1); - expect(fs.writeJSONSync).toHaveBeenCalledWith( - '/mypath/keys.json', + expect(outputUtils.handleOutputFlag).toHaveBeenCalledTimes(1); + expect(outputUtils.handleOutputFlag).toHaveBeenCalledWith( + '/mypath/passphrase.json', { encryptedPassphrase: encryptedPassphraseObject, }, - { - spaces: ' ', - mode: OWNER_READ_WRITE, - }, + 'passphrase', ); }); }); diff --git a/commander/test/utils/output.spec.ts b/commander/test/utils/output.spec.ts new file mode 100644 index 00000000000..5e6270a67d2 --- /dev/null +++ b/commander/test/utils/output.spec.ts @@ -0,0 +1,157 @@ +import * as fs from 'fs-extra'; +// import * as path from 'path'; +import { homedir } from 'os'; +import { handleOutputFlag } from '../../src/utils/output'; +import { OWNER_READ_WRITE } from '../../src/constants'; + +jest.mock('fs-extra'); +// jest.mock('path'); + +describe('handleOutputFlag', () => { + const namespace = 'testNamespace'; + const data = { key: 'value' }; + const outputPath = process.cwd(); + const relativePath = 'testPath'; + const absolutePath = '/testPath'; + const filename = 'testFile.json'; + const error = new Error('write error'); + + beforeEach(() => { + (fs.writeJSONSync as jest.Mock).mockClear(); + }); + + it('should write data to file in the current working directory if outputPath is not provided', async () => { + const outputFilePath = `${outputPath}/${namespace}.json`; + + await handleOutputFlag('', data, namespace); + + expect(fs.writeJSONSync).toHaveBeenCalledWith(outputFilePath, data, { + spaces: ' ', + mode: OWNER_READ_WRITE, + }); + }); + + it('should respond with success message if writing data to file in the current working directory succeeds', async () => { + const outputFilePath = `${outputPath}/${namespace}.json`; + + const res = await handleOutputFlag(outputFilePath, data, namespace); + + expect(res).toBe(`Successfully written data to ${outputFilePath}`); + }); + + it('should throw error if writing data to file in the current working directory fails', async () => { + const outputFilePath = `${outputPath}/${namespace}.json`; + + (fs.writeJSONSync as jest.Mock).mockImplementationOnce(() => { + throw error; + }); + + await expect(handleOutputFlag(outputFilePath, data, namespace)).rejects.toThrow( + `Error writing data to ${outputFilePath}: ${error.toString()}`, + ); + }); + + it('should write data to relative path if outputPath is provided', async () => { + const outputFilePath = `${outputPath}/testPath/${namespace}.json`; + + await handleOutputFlag(relativePath, data, namespace); + + expect(fs.writeJSONSync).toHaveBeenCalledWith(outputFilePath, data, { + spaces: ' ', + mode: OWNER_READ_WRITE, + }); + }); + + it('should respond with success message if writing data to relative path succeeds', async () => { + const outputFilePath = `${outputPath}/testPath/${namespace}.json`; + + const res = await handleOutputFlag(relativePath, data, namespace); + + expect(res).toBe(`Successfully written data to ${outputFilePath}`); + }); + + it('should throw error if writing data to relative path fails', async () => { + const outputFilePath = `${outputPath}/testPath/${namespace}.json`; + + (fs.writeJSONSync as jest.Mock).mockImplementationOnce(() => { + throw error; + }); + + await expect(handleOutputFlag(relativePath, data, namespace)).rejects.toThrow( + `Error writing data to ${outputFilePath}: ${error.toString()}`, + ); + }); + + it('should write data to absolute path if outputPath is provided', async () => { + const outputFilePath = '/testPath/testNamespace.json'; + + await handleOutputFlag(absolutePath, data, namespace); + + expect(fs.writeJSONSync).toHaveBeenCalledWith(outputFilePath, data, { + spaces: ' ', + mode: OWNER_READ_WRITE, + }); + }); + + it('should respond with success message if writing data to absolute path succeeds', async () => { + const outputFilePath = '/testPath/testNamespace.json'; + + const res = await handleOutputFlag(absolutePath, data, namespace); + + expect(res).toBe(`Successfully written data to ${outputFilePath}`); + }); + + it('should throw error if writing data to absolute path fails', async () => { + const outputFilePath = '/testPath/testNamespace.json'; + + (fs.writeJSONSync as jest.Mock).mockImplementationOnce(() => { + throw error; + }); + + await expect(handleOutputFlag(absolutePath, data, namespace)).rejects.toThrow( + `Error writing data to ${outputFilePath}: ${error.toString()}`, + ); + }); + + it('should write data to file in the current working directory if outputPath is provided with filename', async () => { + const outputFilePath = `${outputPath}/${filename}`; + + await handleOutputFlag('', data, namespace, filename); + + expect(fs.writeJSONSync).toHaveBeenCalledWith(outputFilePath, data, { + spaces: ' ', + mode: OWNER_READ_WRITE, + }); + }); + + it('should respond with success message if writing data to file in the current working directory with filename succeeds', async () => { + const outputFilePath = `${outputPath}/${filename}`; + + const res = await handleOutputFlag('', data, namespace, filename); + + expect(res).toBe(`Successfully written data to ${outputFilePath}`); + }); + + it('should throw error if writing data to file in the current working directory with filename fails', async () => { + const outputFilePath = `${outputPath}/${filename}`; + + (fs.writeJSONSync as jest.Mock).mockImplementationOnce(() => { + throw error; + }); + + await expect(handleOutputFlag('', data, namespace, filename)).rejects.toThrow( + `Error writing data to ${outputFilePath}: ${error.toString()}`, + ); + }); + + it('should write data to user home if ~ is provided', async () => { + const outputFilePath = `${homedir()}/${namespace}.json`; + + await handleOutputFlag('~', data, namespace); + + expect(fs.writeJSONSync).toHaveBeenCalledWith(outputFilePath, data, { + spaces: ' ', + mode: OWNER_READ_WRITE, + }); + }); +});