diff --git a/.changeset/clever-cats-smoke.md b/.changeset/clever-cats-smoke.md new file mode 100644 index 0000000000..f5f4278241 --- /dev/null +++ b/.changeset/clever-cats-smoke.md @@ -0,0 +1,7 @@ +--- +'@aws-amplify/backend-secret': minor +'@aws-amplify/backend-cli': minor +'@aws-amplify/backend': patch +--- + +Provides sandbox secret CLI commands diff --git a/package-lock.json b/package-lock.json index 2f57a89234..90a8e0a601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18783,9 +18783,10 @@ "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.3", + "@aws-amplify/form-generator": "^0.2.0-alpha.1", + "@aws-amplify/backend-secret": "^0.2.0-alpha.0", "@aws-amplify/client-config": "^0.2.0-alpha.6", "@aws-amplify/deployed-backend-client": "^0.2.0-alpha.0", - "@aws-amplify/form-generator": "^0.2.0-alpha.1", "@aws-amplify/model-generator": "^0.2.0-alpha.2", "@aws-amplify/sandbox": "^0.2.0-alpha.6", "@aws-sdk/credential-providers": "^3.360.0", diff --git a/packages/backend-secret/API.md b/packages/backend-secret/API.md index 521d05c55a..ba7655b85f 100644 --- a/packages/backend-secret/API.md +++ b/packages/backend-secret/API.md @@ -14,8 +14,7 @@ import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; export const getSecretClient: (credentialProvider?: AwsCredentialIdentityProvider) => SecretClient; // @public -export type Secret = { - secretIdentifier: SecretIdentifier; +export type Secret = SecretIdentifier & { value: string; }; @@ -24,7 +23,7 @@ export type SecretAction = 'GET' | 'SET' | 'REMOVE' | 'LIST'; // @public export type SecretClient = { - getSecret: (backendIdentifier: UniqueBackendIdentifier | BackendId, secretIdentifier: SecretIdentifier) => Promise; + getSecret: (backendIdentifier: UniqueBackendIdentifier | BackendId, secretIdentifier: SecretIdentifier) => Promise; listSecrets: (backendIdentifier: UniqueBackendIdentifier | BackendId) => Promise; setSecret: (backendIdentifier: UniqueBackendIdentifier | BackendId, secretName: string, secretValue: string) => Promise; removeSecret: (backendIdentifier: UniqueBackendIdentifier | BackendId, secretName: string) => Promise; diff --git a/packages/backend-secret/src/secret.ts b/packages/backend-secret/src/secret.ts index e397db3a5d..60f0297492 100644 --- a/packages/backend-secret/src/secret.ts +++ b/packages/backend-secret/src/secret.ts @@ -15,10 +15,7 @@ export type SecretIdentifier = { /** * The secret object. */ -export type Secret = { - secretIdentifier: SecretIdentifier; - value: string; -}; +export type Secret = SecretIdentifier & { value: string }; /** * The client to manage backend secret. @@ -30,7 +27,7 @@ export type SecretClient = { getSecret: ( backendIdentifier: UniqueBackendIdentifier | BackendId, secretIdentifier: SecretIdentifier - ) => Promise; + ) => Promise; /** * List secrets. diff --git a/packages/backend-secret/src/ssm_secret.test.ts b/packages/backend-secret/src/ssm_secret.test.ts index b027d8506f..c2ff3ca778 100644 --- a/packages/backend-secret/src/ssm_secret.test.ts +++ b/packages/backend-secret/src/ssm_secret.test.ts @@ -33,7 +33,7 @@ const testSecretIdWithVersion: SecretIdentifier = { }; const testSecret: Secret = { - secretIdentifier: testSecretIdWithVersion, + ...testSecretIdWithVersion, value: testSecretValue, }; @@ -50,7 +50,7 @@ void describe('SSMSecret', () => { Promise.resolve({ $metadata: {}, Parameter: { - Name: testSecretName, + Name: testBranchSecretFullNamePath, Value: testSecretValue, Version: testSecretVersion, }, @@ -96,6 +96,25 @@ void describe('SSMSecret', () => { }); }); + void it('gets undefined secret value', async () => { + mock.method(ssmClient, 'getParameter', () => + Promise.resolve({ + $metadata: {}, + Parameter: { + Name: testBranchSecretFullNamePath, + Version: testSecretVersion, + }, + }) + ); + const expectedErr = new SecretError( + `The value of secret '${testSecretName}' is undefined` + ); + await assert.rejects( + () => ssmSecretClient.getSecret('', { name: testSecretName }), + expectedErr + ); + }); + void it('throws error', async () => { const ssmNotFoundException = new ParameterNotFound({ $metadata: {}, @@ -105,7 +124,6 @@ void describe('SSMSecret', () => { mock.method(ssmClient, 'getParameter', () => Promise.reject(ssmNotFoundException) ); - const ssmSecretClient = new SSMSecretClient(ssmClient); const expectedErr = SecretError.fromSSMException(ssmNotFoundException); await assert.rejects( () => ssmSecretClient.getSecret('', { name: '' }), diff --git a/packages/backend-secret/src/ssm_secret.ts b/packages/backend-secret/src/ssm_secret.ts index 78aad4f112..10c875be74 100644 --- a/packages/backend-secret/src/ssm_secret.ts +++ b/packages/backend-secret/src/ssm_secret.ts @@ -87,7 +87,8 @@ export class SSMSecretClient implements SecretClient { public getSecret = async ( backendIdentifier: UniqueBackendIdentifier | BackendId, secretIdentifier: SecretIdentifier - ): Promise => { + ): Promise => { + let secret: Secret | undefined; const name = this.getParameterFullPath( backendIdentifier, secretIdentifier.name @@ -96,22 +97,27 @@ export class SSMSecretClient implements SecretClient { const resp = await this.ssmClient.getParameter({ Name: secretIdentifier.version ? `${name}:${secretIdentifier.version}` - : `${name}`, + : name, WithDecryption: true, }); - if (resp.Parameter?.Name && resp.Parameter?.Value) { - return { - secretIdentifier: { - name: resp.Parameter?.Name, - version: resp.Parameter?.Version, - }, + if (resp.Parameter?.Value) { + secret = { + name: secretIdentifier.name, + version: resp.Parameter?.Version, value: resp.Parameter?.Value, }; } - return; } catch (err) { throw SecretError.fromSSMException(err as SSMServiceException); } + + if (!secret) { + throw new SecretError( + `The value of secret '${secretIdentifier.name}' is undefined` + ); + } + + return secret; }; /** diff --git a/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.test.ts b/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.test.ts index 08023595f2..9681b6f10f 100644 --- a/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.test.ts +++ b/packages/backend/src/engine/backend-secret/lambda/backend_secret_fetcher.test.ts @@ -25,7 +25,7 @@ const testSecretId: SecretIdentifier = { }; const testSecret: Secret = { - secretIdentifier: testSecretId, + ...testSecretId, value: testSecretValue, }; diff --git a/packages/cli/package.json b/packages/cli/package.json index 36ad0feeb7..6c5302cf85 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,7 @@ "homepage": "https://github.com/aws-amplify/cli#readme", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.2.0-alpha.3", + "@aws-amplify/backend-secret": "^0.2.0-alpha.0", "@aws-amplify/client-config": "^0.2.0-alpha.6", "@aws-amplify/deployed-backend-client": "^0.2.0-alpha.0", "@aws-amplify/form-generator": "^0.2.0-alpha.1", diff --git a/packages/cli/src/commands/printer/.eslintrc.json b/packages/cli/src/commands/printer/.eslintrc.json new file mode 100644 index 0000000000..d5ba8f9d9c --- /dev/null +++ b/packages/cli/src/commands/printer/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-console": "off" + } +} diff --git a/packages/cli/src/commands/printer/printer.ts b/packages/cli/src/commands/printer/printer.ts new file mode 100644 index 0000000000..8c1ba8293c --- /dev/null +++ b/packages/cli/src/commands/printer/printer.ts @@ -0,0 +1,31 @@ +import { EOL } from 'os'; + +/** + * The class that pretty prints to the console. + */ +export class Printer { + /** + * Print an object/record to console. + */ + static printRecord = >( + object: T + ): void => { + let message = ''; + const entries = Object.entries(object); + entries.forEach(([key, val]) => { + message = message.concat(` ${key}: ${val}${EOL}`); + }); + console.log(message); + }; + + /** + * Prints an array of objects/records to console. + */ + static printRecords = >( + objects: T[] + ): void => { + for (const obj of objects) { + this.printRecord(obj); + } + }; +} diff --git a/packages/cli/src/commands/prompter/amplify_prompts.ts b/packages/cli/src/commands/prompter/amplify_prompts.ts index 3432d5deda..07ec2e597e 100644 --- a/packages/cli/src/commands/prompter/amplify_prompts.ts +++ b/packages/cli/src/commands/prompter/amplify_prompts.ts @@ -1,4 +1,4 @@ -import { confirm } from '@inquirer/prompts'; +import { confirm, password } from '@inquirer/prompts'; /** * Wrapper for prompter library @@ -22,4 +22,17 @@ export class AmplifyPrompter { }); return response; }; + + /** + * A secret prompt. + */ + static secretValue = async ( + promptMessage = 'Enter secret value' + ): Promise => { + return await password({ + message: promptMessage, + validate: (val: string) => + val && val.length > 0 ? true : 'Cannot be empty', + }); + }; } diff --git a/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.test.ts b/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.test.ts index d017cf965b..42c1db9f61 100644 --- a/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.test.ts +++ b/packages/cli/src/commands/sandbox/sandbox-delete/sandbox_delete_command.test.ts @@ -6,6 +6,7 @@ import assert from 'node:assert'; import { SandboxDeleteCommand } from './sandbox_delete_command.js'; import { SandboxCommand } from '../sandbox_command.js'; import { SandboxSingletonFactory } from '@aws-amplify/sandbox'; +import { createSandboxSecretCommand } from '../sandbox-secret/sandbox_secret_command_factory.js'; void describe('sandbox delete command', () => { let commandRunner: TestCommandRunner; @@ -21,11 +22,10 @@ void describe('sandbox delete command', () => { ) as never; // couldn't figure out a good way to type the sandboxDeleteMock so that TS was happy here const sandboxDeleteCommand = new SandboxDeleteCommand(sandboxFactory); - - const sandboxCommand = new SandboxCommand( - sandboxFactory, - sandboxDeleteCommand - ); + const sandboxCommand = new SandboxCommand(sandboxFactory, [ + sandboxDeleteCommand, + createSandboxSecretCommand(), + ]); const parser = yargs().command(sandboxCommand as unknown as CommandModule); commandRunner = new TestCommandRunner(parser); sandboxDeleteMock.mock.resetCalls(); diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/constants.ts b/packages/cli/src/commands/sandbox/sandbox-secret/constants.ts new file mode 100644 index 0000000000..feed1ac615 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/constants.ts @@ -0,0 +1 @@ +export const SANDBOX_BRANCH = 'sandbox'; diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.test.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.test.ts new file mode 100644 index 0000000000..f2ef33d733 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from 'node:test'; +import yargs, { CommandModule } from 'yargs'; +import { + TestCommandError, + TestCommandRunner, +} from '../../../test-utils/command_runner.js'; +import assert from 'node:assert'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { getSecretClient } from '@aws-amplify/backend-secret'; +import { SandboxSecretCommand } from './sandbox_secret_command.js'; +import { SandboxSecretGetCommand } from './sandbox_secret_get_command.js'; + +const testBackendId = 'testBackendId'; + +void describe('sandbox secret command', () => { + const secretClient = getSecretClient(); + const sandboxIdResolver = new SandboxIdResolver({ + resolve: () => Promise.resolve(testBackendId), + }); + + // Creates only a 'get' subcommand. + const sandboxSecretCmd = new SandboxSecretCommand([ + new SandboxSecretGetCommand( + sandboxIdResolver, + secretClient + ) as unknown as CommandModule, + ]); + + const parser = yargs().command(sandboxSecretCmd); + const commandRunner = new TestCommandRunner(parser); + + void it('show --help', async () => { + const output = await commandRunner.runCommand('secret --help'); + assert.match(output, /Manage sandbox secret/); + ['secret get'].forEach((cmd) => assert.match(output, new RegExp(cmd))); + ['secret set', 'secret list', 'secret remove'].forEach((cmd) => + assert.doesNotMatch(output, new RegExp(cmd)) + ); + }); + + void it('throws error if no verb subcommand', async () => { + await assert.rejects( + () => commandRunner.runCommand('secret'), + (err: TestCommandError) => { + assert.equal(err.error.name, 'YError'); + assert.match(err.error.message, /Not enough non-option arguments/); + assert.match(err.output, /Not enough non-option arguments/); + return true; + } + ); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.ts new file mode 100644 index 0000000000..a6568b243d --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command.ts @@ -0,0 +1,56 @@ +import { Argv, CommandModule } from 'yargs'; + +/** + * Root command to manage sandbox secret. + */ +export class SandboxSecretCommand implements CommandModule { + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * Root command to manage sandbox secret + */ + constructor(private readonly secretSubCommands: CommandModule[]) { + this.command = 'secret '; + this.describe = 'Manage sandbox secret'; + } + + /** + * @inheritDoc + */ + handler = (): void => { + // no-op for non-terminal command. + return; + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv): Argv => { + return ( + yargs + .command(this.secretSubCommands) + // Hide inherited options since they are not applicable here. + .option('dirToWatch', { + hidden: true, + }) + .option('exclude', { + hidden: true, + }) + .option('name', { + hidden: true, + }) + .option('out', { + hidden: true, + }) + .help() + ); + }; +} diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command_factory.test.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command_factory.test.ts new file mode 100644 index 0000000000..9a7183abd4 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command_factory.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from 'node:test'; +import yargs from 'yargs'; +import { + TestCommandError, + TestCommandRunner, +} from '../../../test-utils/command_runner.js'; +import assert from 'node:assert'; +import { createSandboxSecretCommand } from './sandbox_secret_command_factory.js'; + +void describe('sandbox secret command factory', () => { + const sandboxSecretCmd = createSandboxSecretCommand(); + + const parser = yargs().command(sandboxSecretCmd); + const commandRunner = new TestCommandRunner(parser); + + void it('show --help', async () => { + const output = await commandRunner.runCommand('secret --help'); + assert.match(output, /Manage sandbox secret/); + ['secret set', 'secret remove', 'secret get ', 'secret list'].forEach( + (cmd) => assert.match(output, new RegExp(cmd)) + ); + }); + + void it('throws error if no verb subcommand', async () => { + await assert.rejects( + () => commandRunner.runCommand('secret'), + (err: TestCommandError) => { + assert.equal(err.error.name, 'YError'); + assert.match(err.error.message, /Not enough non-option arguments/); + assert.match(err.output, /Not enough non-option arguments/); + return true; + } + ); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command_factory.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command_factory.ts new file mode 100644 index 0000000000..f3af92b52f --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_command_factory.ts @@ -0,0 +1,45 @@ +import { CommandModule } from 'yargs'; + +import { LocalAppNameResolver } from '../../../backend-identifier/local_app_name_resolver.js'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { CwdPackageJsonLoader } from '../../../cwd_package_json_loader.js'; +import { SandboxSecretCommand } from './sandbox_secret_command.js'; +import { getSecretClient } from '@aws-amplify/backend-secret'; +import { SandboxSecretSetCommand } from './sandbox_secret_set_command.js'; +import { SandboxSecretRemoveCommand } from './sandbox_secret_remove_command.js'; +import { SandboxSecretGetCommand } from './sandbox_secret_get_command.js'; +import { SandboxSecretListCommand } from './sandbox_secret_list_command.js'; + +/** + * Creates sandbox secret commands. + */ +export const createSandboxSecretCommand = (): CommandModule => { + const sandboxIdResolver = new SandboxIdResolver( + new LocalAppNameResolver(new CwdPackageJsonLoader()) + ); + + const secretClient = getSecretClient(); + const setCommand = new SandboxSecretSetCommand( + sandboxIdResolver, + secretClient + ); + const removeCommand = new SandboxSecretRemoveCommand( + sandboxIdResolver, + secretClient + ); + const getCommand = new SandboxSecretGetCommand( + sandboxIdResolver, + secretClient + ); + const listCommand = new SandboxSecretListCommand( + sandboxIdResolver, + secretClient + ); + + return new SandboxSecretCommand([ + setCommand as unknown as CommandModule, + removeCommand as unknown as CommandModule, + getCommand as unknown as CommandModule, + listCommand, + ]); +}; diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.test.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.test.ts new file mode 100644 index 0000000000..eb4d7970cd --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import yargs, { CommandModule } from 'yargs'; +import { + TestCommandError, + TestCommandRunner, +} from '../../../test-utils/command_runner.js'; +import assert from 'node:assert'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { + Secret, + SecretIdentifier, + getSecretClient, +} from '@aws-amplify/backend-secret'; +import { SandboxSecretGetCommand } from './sandbox_secret_get_command.js'; +import { SANDBOX_BRANCH } from './constants.js'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { Printer } from '../../printer/printer.js'; + +const testSecretName = 'testSecretName'; +const testBackendId = 'testBackendId'; +const testSecretIdentifier: SecretIdentifier = { + name: testSecretName, +}; +const testSecret: Secret = { + ...testSecretIdentifier, + version: 100, + value: 'testValue', +}; + +void describe('sandbox secret get command', () => { + const secretClient = getSecretClient(); + const secretGetMock = mock.method( + secretClient, + 'getSecret', + (): Promise => Promise.resolve(testSecret) + ); + + const sandboxIdResolver = new SandboxIdResolver({ + resolve: () => Promise.resolve(testBackendId), + }); + + const sandboxSecretGetCmd = new SandboxSecretGetCommand( + sandboxIdResolver, + secretClient + ); + + const parser = yargs().command( + sandboxSecretGetCmd as unknown as CommandModule + ); + + const commandRunner = new TestCommandRunner(parser); + + beforeEach(async () => { + secretGetMock.mock.resetCalls(); + }); + + void it('gets a secret', async (contextual) => { + const mockPrintRecord = contextual.mock.method(Printer, 'printRecord'); + + await commandRunner.runCommand(`get ${testSecretName}`); + + assert.equal(secretGetMock.mock.callCount(), 1); + const backendIdentifier = secretGetMock.mock.calls[0] + .arguments[0] as UniqueBackendIdentifier; + assert.match(backendIdentifier.backendId, new RegExp(testBackendId)); + assert.equal(backendIdentifier.branchName, SANDBOX_BRANCH); + assert.deepStrictEqual( + secretGetMock.mock.calls[0].arguments[1], + testSecretIdentifier + ); + + assert.equal(mockPrintRecord.mock.callCount(), 1); + assert.equal(mockPrintRecord.mock.calls[0].arguments[0], testSecret); + }); + + void it('show --help', async () => { + const output = await commandRunner.runCommand('list --help'); + assert.match(output, /Get a sandbox secret/); + }); + + void it('throws error if no secret name argument', async () => { + await assert.rejects( + () => commandRunner.runCommand('get'), + (err: TestCommandError) => { + assert.equal(err.error.name, 'YError'); + assert.match(err.error.message, /Not enough non-option arguments/); + assert.match(err.output, /Not enough non-option arguments/); + return true; + } + ); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.ts new file mode 100644 index 0000000000..e913fc4142 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_get_command.ts @@ -0,0 +1,64 @@ +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; +import { SecretClient } from '@aws-amplify/backend-secret'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { SANDBOX_BRANCH } from './constants.js'; +import { Printer } from '../../printer/printer.js'; + +/** + * Command to get sandbox secret. + */ +export class SandboxSecretGetCommand + implements CommandModule +{ + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * Get sandbox secret command. + */ + constructor( + private readonly sandboxIdResolver: SandboxIdResolver, + private readonly secretClient: SecretClient + ) { + this.command = 'get '; + this.describe = 'Get a sandbox secret'; + } + + /** + * @inheritDoc + */ + handler = async ( + args: ArgumentsCamelCase + ): Promise => { + const backendId = await this.sandboxIdResolver.resolve(); + const secret = await this.secretClient.getSecret( + { backendId, branchName: SANDBOX_BRANCH }, + { name: args.secretName } + ); + Printer.printRecord(secret); + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv): Argv => { + return yargs + .positional('secretName', { + describe: 'Name of the secret to get', + type: 'string', + demandOption: true, + }) + .help(); + }; +} + +type SecretGetCommandOptions = { + secretName: string; +}; diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_list_command.test.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_list_command.test.ts new file mode 100644 index 0000000000..8a20b16277 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_list_command.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import yargs from 'yargs'; +import { TestCommandRunner } from '../../../test-utils/command_runner.js'; +import assert from 'node:assert'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { SecretIdentifier, getSecretClient } from '@aws-amplify/backend-secret'; +import { SANDBOX_BRANCH } from './constants.js'; +import { SandboxSecretListCommand } from './sandbox_secret_list_command.js'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { Printer } from '../../printer/printer.js'; + +const testBackendId = 'testBackendId'; + +const testSecretIds: SecretIdentifier[] = [ + { + name: 'testSecret1', + version: 12, + }, + { + name: 'testSecret2', + version: 24, + }, +]; + +void describe('sandbox secret list command', () => { + const secretClient = getSecretClient(); + const secretListMock = mock.method( + secretClient, + 'listSecrets', + (): Promise => + Promise.resolve(testSecretIds) + ); + const sandboxIdResolver = new SandboxIdResolver({ + resolve: () => Promise.resolve(testBackendId), + }); + + const sandboxSecretListCmd = new SandboxSecretListCommand( + sandboxIdResolver, + secretClient + ); + + const parser = yargs().command(sandboxSecretListCmd); + const commandRunner = new TestCommandRunner(parser); + + beforeEach(async () => { + secretListMock.mock.resetCalls(); + }); + + void it('list secrets', async (contextual) => { + const mockPrintRecords = contextual.mock.method(Printer, 'printRecords'); + + await commandRunner.runCommand(`list`); + assert.equal(secretListMock.mock.callCount(), 1); + + const backendIdentifier = secretListMock.mock.calls[0] + .arguments[0] as UniqueBackendIdentifier; + assert.match(backendIdentifier.backendId, new RegExp(testBackendId)); + assert.equal(backendIdentifier.branchName, SANDBOX_BRANCH); + + assert.equal(mockPrintRecords.mock.callCount(), 1); + assert.equal(mockPrintRecords.mock.calls[0].arguments[0], testSecretIds); + }); + + void it('show --help', async () => { + const output = await commandRunner.runCommand('list --help'); + assert.match(output, /List all sandbox secrets/); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_list_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_list_command.ts new file mode 100644 index 0000000000..fd6ea947ef --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_list_command.ts @@ -0,0 +1,43 @@ +import { CommandModule } from 'yargs'; +import { SecretClient } from '@aws-amplify/backend-secret'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { SANDBOX_BRANCH } from './constants.js'; +import { Printer } from '../../printer/printer.js'; + +/** + * Command to list sandbox secrets. + */ +export class SandboxSecretListCommand implements CommandModule { + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * List sandbox secret command. + */ + constructor( + private readonly sandboxIdResolver: SandboxIdResolver, + private readonly secretClient: SecretClient + ) { + this.command = 'list'; + this.describe = 'List all sandbox secrets'; + } + + /** + * @inheritDoc + */ + handler = async (): Promise => { + const backendId = await this.sandboxIdResolver.resolve(); + const secretIds = await this.secretClient.listSecrets({ + backendId, + branchName: SANDBOX_BRANCH, + }); + Printer.printRecords(secretIds); + }; +} diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.test.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.test.ts new file mode 100644 index 0000000000..429841ba5a --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import yargs, { CommandModule } from 'yargs'; +import { + TestCommandError, + TestCommandRunner, +} from '../../../test-utils/command_runner.js'; +import assert from 'node:assert'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { getSecretClient } from '@aws-amplify/backend-secret'; +import { SandboxSecretRemoveCommand } from './sandbox_secret_remove_command.js'; +import { SANDBOX_BRANCH } from './constants.js'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; + +const testSecretName = 'testSecretName'; +const testBackendId = 'testBackendId'; + +void describe('sandbox secret remove command', () => { + const secretClient = getSecretClient(); + const secretRemoveMock = mock.method( + secretClient, + 'removeSecret', + (): Promise => Promise.resolve() + ); + + const sandboxIdResolver = new SandboxIdResolver({ + resolve: () => Promise.resolve('testBackendId'), + }); + + const sandboxSecretRemoveCmd = new SandboxSecretRemoveCommand( + sandboxIdResolver, + secretClient + ); + + const parser = yargs().command( + sandboxSecretRemoveCmd as unknown as CommandModule + ); + + const commandRunner = new TestCommandRunner(parser); + + beforeEach(async () => { + secretRemoveMock.mock.resetCalls(); + }); + + void it('remove a secret', async () => { + await commandRunner.runCommand(`remove ${testSecretName}`); + assert.equal(secretRemoveMock.mock.callCount(), 1); + const backendIdentifier = secretRemoveMock.mock.calls[0] + .arguments[0] as UniqueBackendIdentifier; + assert.match(backendIdentifier.backendId, new RegExp(testBackendId)); + assert.equal(backendIdentifier.branchName, SANDBOX_BRANCH); + assert.equal(secretRemoveMock.mock.calls[0].arguments[1], testSecretName); + }); + + void it('show --help', async () => { + const output = await commandRunner.runCommand('remove --help'); + assert.match(output, /Remove a sandbox secret/); + }); + + void it('throws error if no secret name argument', async () => { + await assert.rejects( + () => commandRunner.runCommand('remove'), + (err: TestCommandError) => { + assert.equal(err.error.name, 'YError'); + assert.match(err.error.message, /Not enough non-option arguments/); + assert.match(err.output, /Not enough non-option arguments/); + return true; + } + ); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts new file mode 100644 index 0000000000..5922820e18 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts @@ -0,0 +1,63 @@ +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; +import { SecretClient } from '@aws-amplify/backend-secret'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { SANDBOX_BRANCH } from './constants.js'; + +/** + * Command to remove sandbox secret. + */ +export class SandboxSecretRemoveCommand + implements CommandModule +{ + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * Remove sandbox secret command. + */ + constructor( + private readonly sandboxIdResolver: SandboxIdResolver, + private readonly secretClient: SecretClient + ) { + this.command = 'remove '; + this.describe = 'Remove a sandbox secret'; + } + + /** + * @inheritDoc + */ + handler = async ( + args: ArgumentsCamelCase + ): Promise => { + const backendId = await this.sandboxIdResolver.resolve(); + await this.secretClient.removeSecret( + { + backendId, + branchName: SANDBOX_BRANCH, + }, + args.secretName + ); + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv): Argv => { + return yargs.positional('secretName', { + describe: 'Name of the secret to remove', + type: 'string', + demandOption: true, + }); + }; +} + +type SecretRemoveCommandOptions = { + secretName: string; +}; diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.test.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.test.ts new file mode 100644 index 0000000000..ad3d112ed7 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import { AmplifyPrompter } from '../../prompter/amplify_prompts.js'; +import yargs, { CommandModule } from 'yargs'; +import { + TestCommandError, + TestCommandRunner, +} from '../../../test-utils/command_runner.js'; +import assert from 'node:assert'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { SecretIdentifier, getSecretClient } from '@aws-amplify/backend-secret'; +import { SANDBOX_BRANCH } from './constants.js'; +import { SandboxSecretSetCommand } from './sandbox_secret_set_command.js'; +import { UniqueBackendIdentifier } from '@aws-amplify/plugin-types'; +import { Printer } from '../../printer/printer.js'; + +const testSecretName = 'testSecretName'; +const testSecretValue = 'testSecretValue'; +const testSecretIdentifier: SecretIdentifier = { + name: testSecretName, + version: 100, +}; + +const testBackendId = 'testBackendId'; + +void describe('sandbox secret set command', () => { + const secretClient = getSecretClient(); + const secretSetMock = mock.method( + secretClient, + 'setSecret', + (): Promise => Promise.resolve(testSecretIdentifier) + ); + + const sandboxIdResolver = new SandboxIdResolver({ + resolve: () => Promise.resolve(testBackendId), + }); + + const sandboxSecretSetCmd = new SandboxSecretSetCommand( + sandboxIdResolver, + secretClient + ); + + const parser = yargs().command( + sandboxSecretSetCmd as unknown as CommandModule + ); + + const commandRunner = new TestCommandRunner(parser); + + beforeEach(async () => { + secretSetMock.mock.resetCalls(); + }); + + void it('sets a secret', async (contextual) => { + const mockSecretValue = contextual.mock.method( + AmplifyPrompter, + 'secretValue', + () => Promise.resolve(testSecretValue) + ); + + const mockPrintRecord = contextual.mock.method(Printer, 'printRecord'); + + await commandRunner.runCommand(`set ${testSecretName}`); + assert.equal(mockSecretValue.mock.callCount(), 1); + assert.equal(mockPrintRecord.mock.callCount(), 1); + assert.deepStrictEqual( + mockPrintRecord.mock.calls[0].arguments[0], + testSecretIdentifier + ); + assert.equal(secretSetMock.mock.callCount(), 1); + + const backendIdentifier = secretSetMock.mock.calls[0] + .arguments[0] as UniqueBackendIdentifier; + assert.match(backendIdentifier.backendId, new RegExp(testBackendId)); + assert.equal(backendIdentifier.branchName, SANDBOX_BRANCH); + assert.equal(secretSetMock.mock.calls[0].arguments[1], testSecretName); + assert.equal(secretSetMock.mock.calls[0].arguments[2], testSecretValue); + }); + + void it('show --help', async () => { + const output = await commandRunner.runCommand('set --help'); + assert.match(output, /Set a sandbox secret/); + }); + + void it('throws error if no secret name argument', async () => { + await assert.rejects( + () => commandRunner.runCommand('set'), + (err: TestCommandError) => { + assert.equal(err.error.name, 'YError'); + assert.match(err.error.message, /Not enough non-option arguments/); + assert.match(err.output, /Not enough non-option arguments/); + return true; + } + ); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.ts b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.ts new file mode 100644 index 0000000000..583c2d6769 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_set_command.ts @@ -0,0 +1,65 @@ +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; +import { SecretClient } from '@aws-amplify/backend-secret'; +import { SandboxIdResolver } from '../sandbox_id_resolver.js'; +import { SANDBOX_BRANCH } from './constants.js'; +import { AmplifyPrompter } from '../../prompter/amplify_prompts.js'; +import { Printer } from '../../printer/printer.js'; + +/** + * Command to set sandbox secret. + */ +export class SandboxSecretSetCommand + implements CommandModule +{ + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * Set sandbox secret command. + */ + constructor( + private readonly sandboxIdResolver: SandboxIdResolver, + private readonly secretClient: SecretClient + ) { + this.command = 'set '; + this.describe = 'Set a sandbox secret'; + } + + /** + * @inheritDoc + */ + handler = async ( + args: ArgumentsCamelCase + ): Promise => { + const secretVal = await AmplifyPrompter.secretValue(); + const backendId = await this.sandboxIdResolver.resolve(); + const secretId = await this.secretClient.setSecret( + { backendId, branchName: SANDBOX_BRANCH }, + args.secretName, + secretVal + ); + Printer.printRecord(secretId); + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv): Argv => { + return yargs.positional('secretName', { + describe: 'Name of the secret to set', + type: 'string', + demandOption: true, + }); + }; +} + +type SecretSetCommandOptions = { + secretName: string; +}; diff --git a/packages/cli/src/commands/sandbox/sandbox_command.test.ts b/packages/cli/src/commands/sandbox/sandbox_command.test.ts index 7cd3a57f85..62d5c93da7 100644 --- a/packages/cli/src/commands/sandbox/sandbox_command.test.ts +++ b/packages/cli/src/commands/sandbox/sandbox_command.test.ts @@ -11,6 +11,7 @@ import { SandboxCommand } from './sandbox_command.js'; import { createSandboxCommand } from './sandbox_command_factory.js'; import { SandboxDeleteCommand } from './sandbox-delete/sandbox_delete_command.js'; import { Sandbox, SandboxSingletonFactory } from '@aws-amplify/sandbox'; +import { createSandboxSecretCommand } from './sandbox-secret/sandbox_secret_command_factory.js'; void describe('sandbox command factory', () => { void it('instantiate a sandbox command correctly', () => { @@ -31,11 +32,10 @@ void describe('sandbox command', () => { sandboxStartMock = mock.method(sandbox, 'start', () => Promise.resolve()); const sandboxDeleteCommand = new SandboxDeleteCommand(sandboxFactory); - - const sandboxCommand = new SandboxCommand( - sandboxFactory, - sandboxDeleteCommand - ); + const sandboxCommand = new SandboxCommand(sandboxFactory, [ + sandboxDeleteCommand, + createSandboxSecretCommand(), + ]); const parser = yargs().command(sandboxCommand as unknown as CommandModule); commandRunner = new TestCommandRunner(parser); sandboxStartMock.mock.resetCalls(); diff --git a/packages/cli/src/commands/sandbox/sandbox_command.ts b/packages/cli/src/commands/sandbox/sandbox_command.ts index f2dae5010f..64a2e2cba8 100644 --- a/packages/cli/src/commands/sandbox/sandbox_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox_command.ts @@ -1,6 +1,5 @@ import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; import { ClientConfigFormat } from '@aws-amplify/client-config'; -import { SandboxDeleteCommand } from './sandbox-delete/sandbox_delete_command.js'; import fs from 'fs'; import { AmplifyPrompter } from '../prompter/amplify_prompts.js'; import { SandboxSingletonFactory } from '@aws-amplify/sandbox'; @@ -37,7 +36,7 @@ export class SandboxCommand */ constructor( private readonly sandboxFactory: SandboxSingletonFactory, - private readonly sandboxDeleteCommand: SandboxDeleteCommand + private readonly sandboxSubCommands: CommandModule[] ) { this.command = 'sandbox'; this.describe = 'Starts sandbox, watch mode for amplify deployments'; @@ -70,7 +69,7 @@ export class SandboxCommand return ( yargs // Cast to erase options types used in internal sub command implementation. Otherwise, compiler fails here. - .command(this.sandboxDeleteCommand as unknown as CommandModule) + .command(this.sandboxSubCommands) .option('dirToWatch', { describe: 'Directory to watch for file changes. All subdirectories and files will be included. defaults to the current directory.', diff --git a/packages/cli/src/commands/sandbox/sandbox_command_factory.ts b/packages/cli/src/commands/sandbox/sandbox_command_factory.ts index 5101f075a6..56b0681c00 100644 --- a/packages/cli/src/commands/sandbox/sandbox_command_factory.ts +++ b/packages/cli/src/commands/sandbox/sandbox_command_factory.ts @@ -6,6 +6,7 @@ import { SandboxDeleteCommand } from './sandbox-delete/sandbox_delete_command.js import { SandboxIdResolver } from './sandbox_id_resolver.js'; import { CwdPackageJsonLoader } from '../../cwd_package_json_loader.js'; import { LocalAppNameResolver } from '../../backend-identifier/local_app_name_resolver.js'; +import { createSandboxSecretCommand } from './sandbox-secret/sandbox_secret_command_factory.js'; /** * Creates wired sandbox command. @@ -18,8 +19,9 @@ export const createSandboxCommand = (): CommandModule< new LocalAppNameResolver(new CwdPackageJsonLoader()) ); const sandboxFactory = new SandboxSingletonFactory(sandboxIdResolver.resolve); - return new SandboxCommand( - sandboxFactory, - new SandboxDeleteCommand(sandboxFactory) - ); + + return new SandboxCommand(sandboxFactory, [ + new SandboxDeleteCommand(sandboxFactory), + createSandboxSecretCommand(), + ]); }; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 304f90200f..68717580c4 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "lib" }, "references": [ { "path": "../backend-output-schemas" }, + { "path": "../backend-secret" }, { "path": "../client-config" }, { "path": "../deployed-backend-client" }, { "path": "../form-generator" },