Skip to content

Commit

Permalink
wrap credential related errors for generate in AmplifyUserError (#1832)
Browse files Browse the repository at this point in the history
* wrap credential related errors for generate in AmplifyUserError

* pr feedback
  • Loading branch information
rtpascual authored Aug 12, 2024
1 parent 9c69814 commit eab6ddb
Show file tree
Hide file tree
Showing 13 changed files with 563 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .changeset/pink-bees-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@aws-amplify/deployed-backend-client': minor
'@aws-amplify/model-generator': patch
'@aws-amplify/client-config': patch
'@aws-amplify/backend-cli': patch
---

wrap credential related errors for generate commands in AmplifyUserError
Original file line number Diff line number Diff line change
Expand Up @@ -329,4 +329,98 @@ void describe('generate forms command', () => {
}
);
});

void it('throws user error if credentials are expired when getting backend outputs', async () => {
const fakeSandboxId = 'my-fake-app-my-fake-username';
const backendIdResolver = {
resolve: mock.fn(() =>
Promise.resolve({
namespace: fakeSandboxId,
name: fakeSandboxId,
type: 'sandbox',
})
),
} as BackendIdentifierResolver;
const formGenerationHandler = new FormGenerationHandler({
awsClientProvider,
});

const fakedBackendOutputClient = {
getOutput: mock.fn(() => {
throw new BackendOutputClientError(
BackendOutputClientErrorType.CREDENTIALS_ERROR,
'token is expired'
);
}),
};

const generateFormsCommand = new GenerateFormsCommand(
backendIdResolver,
() => fakedBackendOutputClient,
formGenerationHandler
);

const parser = yargs().command(
generateFormsCommand as unknown as CommandModule
);
const commandRunner = new TestCommandRunner(parser);
await assert.rejects(
() => commandRunner.runCommand('forms'),
(error: TestCommandError) => {
assert.strictEqual(error.error.name, 'CredentialsError');
assert.strictEqual(
error.error.message,
'Unable to get backend outputs due to invalid credentials.'
);
return true;
}
);
});

void it('throws user error if access is denied when getting backend outputs', async () => {
const fakeSandboxId = 'my-fake-app-my-fake-username';
const backendIdResolver = {
resolve: mock.fn(() =>
Promise.resolve({
namespace: fakeSandboxId,
name: fakeSandboxId,
type: 'sandbox',
})
),
} as BackendIdentifierResolver;
const formGenerationHandler = new FormGenerationHandler({
awsClientProvider,
});

const fakedBackendOutputClient = {
getOutput: mock.fn(() => {
throw new BackendOutputClientError(
BackendOutputClientErrorType.ACCESS_DENIED,
'access is denied'
);
}),
};

const generateFormsCommand = new GenerateFormsCommand(
backendIdResolver,
() => fakedBackendOutputClient,
formGenerationHandler
);

const parser = yargs().command(
generateFormsCommand as unknown as CommandModule
);
const commandRunner = new TestCommandRunner(parser);
await assert.rejects(
() => commandRunner.runCommand('forms'),
(error: TestCommandError) => {
assert.strictEqual(error.error.name, 'AccessDenied');
assert.strictEqual(
error.error.message,
'Unable to get backend outputs due to insufficient permissions.'
);
return true;
}
);
});
});
31 changes: 31 additions & 0 deletions packages/cli/src/commands/generate/forms/generate_forms_command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,37 @@ export class GenerateFormsCommand
error
);
}
if (
error instanceof BackendOutputClientError &&
error.code === BackendOutputClientErrorType.CREDENTIALS_ERROR
) {
throw new AmplifyUserError(
'CredentialsError',
{
message:
'Unable to get backend outputs due to invalid credentials.',
resolution:
'Ensure your AWS credentials are correctly set and refreshed.',
},
error
);
}
if (
error instanceof BackendOutputClientError &&
error.code === BackendOutputClientErrorType.ACCESS_DENIED
) {
throw new AmplifyUserError(
'AccessDenied',
{
message:
'Unable to get backend outputs due to insufficient permissions.',
resolution:
'Ensure you have permissions to call cloudformation:GetTemplateSummary.',
},
error
);
}

throw error;
}

Expand Down
66 changes: 66 additions & 0 deletions packages/client-config/src/unified_client_config_generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,5 +233,71 @@ void describe('UnifiedClientConfigGenerator', () => {
}
);
});

void it('throws user error if credentials are expired when getting backend outputs', async () => {
const outputRetrieval = mock.fn(() => {
throw new BackendOutputClientError(
BackendOutputClientErrorType.CREDENTIALS_ERROR,
'token is expired'
);
});
const modelSchemaAdapter = new ModelIntrospectionSchemaAdapter(
stubClientProvider
);

const configContributors = new ClientConfigContributorFactory(
modelSchemaAdapter
).getContributors('1');

const clientConfigGenerator = new UnifiedClientConfigGenerator(
outputRetrieval,
configContributors
);

await assert.rejects(
() => clientConfigGenerator.generateClientConfig(),
(error: AmplifyUserError) => {
assert.strictEqual(
error.message,
'Unable to get backend outputs due to invalid credentials.'
);
assert.ok(error.resolution);
return true;
}
);
});

void it('throws user error if access is denied when getting backend outputs', async () => {
const outputRetrieval = mock.fn(() => {
throw new BackendOutputClientError(
BackendOutputClientErrorType.ACCESS_DENIED,
'access is denied'
);
});
const modelSchemaAdapter = new ModelIntrospectionSchemaAdapter(
stubClientProvider
);

const configContributors = new ClientConfigContributorFactory(
modelSchemaAdapter
).getContributors('1');

const clientConfigGenerator = new UnifiedClientConfigGenerator(
outputRetrieval,
configContributors
);

await assert.rejects(
() => clientConfigGenerator.generateClientConfig(),
(error: AmplifyUserError) => {
assert.strictEqual(
error.message,
'Unable to get backend outputs due to insufficient permissions.'
);
assert.ok(error.resolution);
return true;
}
);
});
});
});
31 changes: 31 additions & 0 deletions packages/client-config/src/unified_client_config_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ export class UnifiedClientConfigGenerator implements ClientConfigGenerator {
error
);
}
if (
error instanceof BackendOutputClientError &&
error.code === BackendOutputClientErrorType.CREDENTIALS_ERROR
) {
throw new AmplifyUserError(
'CredentialsError',
{
message:
'Unable to get backend outputs due to invalid credentials.',
resolution:
'Ensure your AWS credentials are correctly set and refreshed.',
},
error
);
}
if (
error instanceof BackendOutputClientError &&
error.code === BackendOutputClientErrorType.ACCESS_DENIED
) {
throw new AmplifyUserError(
'AccessDenied',
{
message:
'Unable to get backend outputs due to insufficient permissions.',
resolution:
'Ensure you have permissions to call cloudformation:GetTemplateSummary.',
},
error
);
}

throw error;
}
const backendOutput = unifiedBackendOutputSchema.parse(output);
Expand Down
4 changes: 4 additions & 0 deletions packages/deployed-backend-client/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export class BackendOutputClientError extends Error {

// @public (undocumented)
export enum BackendOutputClientErrorType {
// (undocumented)
ACCESS_DENIED = "AccessDenied",
// (undocumented)
CREDENTIALS_ERROR = "CredentialsError",
// (undocumented)
DEPLOYMENT_IN_PROGRESS = "DeploymentInProgress",
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export enum BackendOutputClientErrorType {
NO_OUTPUTS_FOUND = 'NoOutputsFound',
DEPLOYMENT_IN_PROGRESS = 'DeploymentInProgress',
NO_STACK_FOUND = 'NoStackFound',
CREDENTIALS_ERROR = 'CredentialsError',
ACCESS_DENIED = 'AccessDenied',
}
/**
* Error type for BackendOutputClientError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,99 @@ void describe('StackMetadataBackendOutputRetrievalStrategy', () => {
);
});

void it('throws if security token is expired', async () => {
const cfnClientMock = {
send: mock.fn((command) => {
if (command instanceof GetTemplateSummaryCommand) {
throw new CloudFormationServiceException({
$fault: 'client',
$metadata: {},
name: 'ExpiredToken',
message: 'The security token included in the request is expired',
});
} else if (command instanceof DescribeStacksCommand) {
return {
Stacks: [
{
Outputs: [
{
OutputKey: 'testName1',
OutputValue: 'testValue1',
},
],
},
],
};
}
assert.fail(`Unknown command ${typeof command}`);
}),
} as unknown as CloudFormationClient;

const stackNameResolverMock: MainStackNameResolver = {
resolveMainStackName: mock.fn(async () => 'randomStack'),
};

const retrievalStrategy = new StackMetadataBackendOutputRetrievalStrategy(
cfnClientMock,
stackNameResolverMock
);

await assert.rejects(
retrievalStrategy.fetchBackendOutput(),
new BackendOutputClientError(
BackendOutputClientErrorType.CREDENTIALS_ERROR,
'The security token included in the request is expired'
)
);
});

void it('throws if access is denied when getting template summary', async () => {
const cfnClientMock = {
send: mock.fn((command) => {
if (command instanceof GetTemplateSummaryCommand) {
throw new CloudFormationServiceException({
$fault: 'client',
$metadata: {},
name: 'AccessDenied',
message:
'role is not authorized to perform: randomAction on resource: resource-arn because no identity-based policy allows the randomAction action',
});
} else if (command instanceof DescribeStacksCommand) {
return {
Stacks: [
{
Outputs: [
{
OutputKey: 'testName1',
OutputValue: 'testValue1',
},
],
},
],
};
}
assert.fail(`Unknown command ${typeof command}`);
}),
} as unknown as CloudFormationClient;

const stackNameResolverMock: MainStackNameResolver = {
resolveMainStackName: mock.fn(async () => 'randomStack'),
};

const retrievalStrategy = new StackMetadataBackendOutputRetrievalStrategy(
cfnClientMock,
stackNameResolverMock
);

await assert.rejects(
retrievalStrategy.fetchBackendOutput(),
new BackendOutputClientError(
BackendOutputClientErrorType.ACCESS_DENIED,
'role is not authorized to perform: randomAction on resource: resource-arn because no identity-based policy allows the randomAction action'
)
);
});

void it('does not throw on missing output', async () => {
const cfnClientMock = {
send: mock.fn((command) => {
Expand Down
Loading

0 comments on commit eab6ddb

Please sign in to comment.