diff --git a/packages/cli/src/backend-identifier/backend_identifier_resolver.test.ts b/packages/cli/src/backend-identifier/backend_identifier_resolver.test.ts index 57e9406ec8a..f2b1c981d69 100644 --- a/packages/cli/src/backend-identifier/backend_identifier_resolver.test.ts +++ b/packages/cli/src/backend-identifier/backend_identifier_resolver.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert'; -import { describe, it } from 'node:test'; +import { beforeEach, describe, it, mock } from 'node:test'; import { AppBackendIdentifierResolver } from './backend_identifier_resolver.js'; +import { AppIdValidator } from '@aws-amplify/deployed-backend-client'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; void describe('BackendIdentifierResolver', () => { void describe('resolveDeployedBackendIdentifier', () => { @@ -45,9 +47,79 @@ void describe('BackendIdentifierResolver', () => { }, ); }); + + void describe('with app ID validation', () => { + const mockAppIdValidator = { + validateAppId: mock.fn(() => Promise.resolve()), + } as unknown as AppIdValidator; + + beforeEach(() => { + mock.reset(); + }); + + void it('validates app ID when provided', async () => { + const backendIdResolver = new AppBackendIdentifierResolver( + { + resolve: () => Promise.resolve('testAppName'), + }, + mockAppIdValidator + ); + + await backendIdResolver.resolveDeployedBackendIdentifier({ + appId: 'valid-app-id', + branch: 'test-branch', + }); + + assert.equal(mockAppIdValidator.validateAppId.mock.callCount(), 1); + assert.deepEqual(mockAppIdValidator.validateAppId.mock.calls[0].arguments, ['valid-app-id']); + }); + + void it('propagates validation errors', async () => { + const validationError = new AmplifyUserError('InvalidAppIdError', { + message: 'App ID does not exist', + resolution: 'Use a valid app ID', + }); + + mockAppIdValidator.validateAppId = mock.fn(() => Promise.reject(validationError)); + + const backendIdResolver = new AppBackendIdentifierResolver( + { + resolve: () => Promise.resolve('testAppName'), + }, + mockAppIdValidator + ); + + await assert.rejects( + () => backendIdResolver.resolveDeployedBackendIdentifier({ + appId: 'invalid-app-id', + branch: 'test-branch', + }), + (error) => { + assert(error instanceof AmplifyUserError); + assert.equal(error.name, 'InvalidAppIdError'); + return true; + } + ); + }); + + void it('does not validate when app ID is not provided', async () => { + const backendIdResolver = new AppBackendIdentifierResolver( + { + resolve: () => Promise.resolve('testAppName'), + }, + mockAppIdValidator + ); + + await backendIdResolver.resolveDeployedBackendIdentifier({ + branch: 'test-branch', + }); + + assert.equal(mockAppIdValidator.validateAppId.mock.callCount(), 0); + }); + }); }); - void describe('resolveDeployedBackendIdToBackendId', () => { + void describe('resolveBackendIdentifier', () => { void it('returns backend identifier from App Name and Branch identifier', async () => { const backendIdResolver = new AppBackendIdentifierResolver({ resolve: () => Promise.resolve('testAppName'), @@ -80,5 +152,60 @@ void describe('BackendIdentifierResolver', () => { }, ); }); + + void describe('with app ID validation', () => { + const mockAppIdValidator = { + validateAppId: mock.fn(() => Promise.resolve()), + } as unknown as AppIdValidator; + + beforeEach(() => { + mock.reset(); + }); + + void it('validates app ID when provided', async () => { + const backendIdResolver = new AppBackendIdentifierResolver( + { + resolve: () => Promise.resolve('testAppName'), + }, + mockAppIdValidator + ); + + await backendIdResolver.resolveBackendIdentifier({ + appId: 'valid-app-id', + branch: 'test-branch', + }); + + assert.equal(mockAppIdValidator.validateAppId.mock.callCount(), 1); + assert.deepEqual(mockAppIdValidator.validateAppId.mock.calls[0].arguments, ['valid-app-id']); + }); + + void it('propagates validation errors', async () => { + const validationError = new AmplifyUserError('InvalidAppIdError', { + message: 'App ID does not exist', + resolution: 'Use a valid app ID', + }); + + mockAppIdValidator.validateAppId = mock.fn(() => Promise.reject(validationError)); + + const backendIdResolver = new AppBackendIdentifierResolver( + { + resolve: () => Promise.resolve('testAppName'), + }, + mockAppIdValidator + ); + + await assert.rejects( + () => backendIdResolver.resolveBackendIdentifier({ + appId: 'invalid-app-id', + branch: 'test-branch', + }), + (error) => { + assert(error instanceof AmplifyUserError); + assert.equal(error.name, 'InvalidAppIdError'); + return true; + } + ); + }); + }); }); }); diff --git a/packages/cli/src/backend-identifier/backend_identifier_resolver.ts b/packages/cli/src/backend-identifier/backend_identifier_resolver.ts index 4df25a419f1..ed7c90b42b9 100644 --- a/packages/cli/src/backend-identifier/backend_identifier_resolver.ts +++ b/packages/cli/src/backend-identifier/backend_identifier_resolver.ts @@ -1,4 +1,4 @@ -import { DeployedBackendIdentifier } from '@aws-amplify/deployed-backend-client'; +import { AppIdValidator, DeployedBackendIdentifier } from '@aws-amplify/deployed-backend-client'; import { NamespaceResolver } from './local_namespace_resolver.js'; import { BackendIdentifier } from '@aws-amplify/plugin-types'; import { BackendIdentifierConversions } from '@aws-amplify/platform-core'; @@ -26,10 +26,19 @@ export class AppBackendIdentifierResolver implements BackendIdentifierResolver { /** * Instantiates BackendIdentifierResolver */ - constructor(private readonly namespaceResolver: NamespaceResolver) {} + constructor( + private readonly namespaceResolver: NamespaceResolver, + private readonly appIdValidator?: AppIdValidator + ) {} + resolveDeployedBackendIdentifier = async ( args: BackendIdentifierParameters, ): Promise => { + // Validate appId if provided and validator is available + if (args.appId && this.appIdValidator) { + await this.appIdValidator.validateAppId(args.appId); + } + if (args.stack) { return { stackName: args.stack }; } else if (args.appId && args.branch) { @@ -46,9 +55,15 @@ export class AppBackendIdentifierResolver implements BackendIdentifierResolver { } return undefined; }; + resolveBackendIdentifier = async ( args: BackendIdentifierParameters, ): Promise => { + // Validate appId if provided and validator is available + if (args.appId && this.appIdValidator) { + await this.appIdValidator.validateAppId(args.appId); + } + if (args.stack) { return BackendIdentifierConversions.fromStackName(args.stack); } else if (args.appId && args.branch) { diff --git a/packages/cli/src/backend-identifier/backend_identifier_with_sandbox_fallback.test.ts b/packages/cli/src/backend-identifier/backend_identifier_with_sandbox_fallback.test.ts index 1972d4cde57..5fd0d1ac329 100644 --- a/packages/cli/src/backend-identifier/backend_identifier_with_sandbox_fallback.test.ts +++ b/packages/cli/src/backend-identifier/backend_identifier_with_sandbox_fallback.test.ts @@ -1,15 +1,20 @@ import assert from 'node:assert'; -import { it } from 'node:test'; +import { it, mock } from 'node:test'; import { SandboxBackendIdResolver } from '../commands/sandbox/sandbox_id_resolver.js'; import { AppBackendIdentifierResolver } from './backend_identifier_resolver.js'; import { BackendIdentifierResolverWithFallback } from './backend_identifier_with_sandbox_fallback.js'; +import { AppIdValidator } from '@aws-amplify/deployed-backend-client'; void it('if backend identifier resolves without error, the resolved id is returned', async () => { const namespaceResolver = { resolve: () => Promise.resolve('testAppName'), }; - const defaultResolver = new AppBackendIdentifierResolver(namespaceResolver); + const mockAppIdValidator = { + validateAppId: mock.fn(() => Promise.resolve()), + } as unknown as AppIdValidator; + + const defaultResolver = new AppBackendIdentifierResolver(namespaceResolver, mockAppIdValidator); const sandboxResolver = new SandboxBackendIdResolver(namespaceResolver); const backendIdResolver = new BackendIdentifierResolverWithFallback( defaultResolver, @@ -24,6 +29,10 @@ void it('if backend identifier resolves without error, the resolved id is return name: 'world', type: 'branch', }); + + // Verify that the app ID validator was called + assert.equal(mockAppIdValidator.validateAppId.mock.callCount(), 1); + assert.deepEqual(mockAppIdValidator.validateAppId.mock.calls[0].arguments, ['hello']); }); void it('uses the sandbox id if the default identifier resolver fails and there is no stack, appId or branch in args', async () => { diff --git a/packages/cli/src/commands/generate/generate_command_factory.ts b/packages/cli/src/commands/generate/generate_command_factory.ts index 48a986fe7a7..1842f2166d7 100644 --- a/packages/cli/src/commands/generate/generate_command_factory.ts +++ b/packages/cli/src/commands/generate/generate_command_factory.ts @@ -8,7 +8,7 @@ import { LocalNamespaceResolver } from '../../backend-identifier/local_namespace import { ClientConfigGeneratorAdapter } from '../../client-config/client_config_generator_adapter.js'; import { GenerateApiCodeAdapter } from './graphql-client-code/generate_api_code_adapter.js'; import { FormGenerationHandler } from '../../form-generation/form_generation_handler.js'; -import { BackendOutputClientFactory } from '@aws-amplify/deployed-backend-client'; +import { AppIdValidator, BackendOutputClientFactory } from '@aws-amplify/deployed-backend-client'; import { SandboxBackendIdResolver } from '../sandbox/sandbox_id_resolver.js'; import { CommandMiddleware } from '../../command_middleware.js'; import { BackendIdentifierResolverWithFallback } from '../../backend-identifier/backend_identifier_with_sandbox_fallback.js'; @@ -41,9 +41,12 @@ export const createGenerateCommand = (): CommandModule => { ); const namespaceResolver = new LocalNamespaceResolver(new PackageJsonReader()); + + // Create the AppIdValidator to validate app IDs + const appIdValidator = new AppIdValidator(amplifyClient); const backendIdentifierResolver = new BackendIdentifierResolverWithFallback( - new AppBackendIdentifierResolver(namespaceResolver), + new AppBackendIdentifierResolver(namespaceResolver, appIdValidator), new SandboxBackendIdResolver(namespaceResolver), ); diff --git a/packages/deployed-backend-client/src/app_id_validator.test.ts b/packages/deployed-backend-client/src/app_id_validator.test.ts new file mode 100644 index 00000000000..cfb4ba29ea7 --- /dev/null +++ b/packages/deployed-backend-client/src/app_id_validator.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import { AmplifyClient } from '@aws-sdk/client-amplify'; +import { AppIdValidator } from './app_id_validator.js'; +import assert from 'node:assert'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; + +void describe('AppIdValidator', () => { + const amplifyClientMock = new AmplifyClient({ region: 'test-region' }); + const amplifyClientSendMock = mock.fn(() => Promise.resolve({})); + const amplifyClientConfigRegionMock = mock.fn(() => Promise.resolve('test-region')); + + mock.method(amplifyClientMock, 'send', amplifyClientSendMock); + mock.method(amplifyClientMock.config, 'region', amplifyClientConfigRegionMock); + + beforeEach(() => { + amplifyClientSendMock.mock.resetCalls(); + amplifyClientConfigRegionMock.mock.resetCalls(); + }); + + void it('succeeds when app ID exists', async () => { + const validator = new AppIdValidator(amplifyClientMock); + await validator.validateAppId('valid-app-id'); + assert.equal(amplifyClientSendMock.mock.callCount(), 1); + }); + + void it('throws AmplifyUserError when app ID does not exist (NotFoundException)', async () => { + const notFoundError = new Error('App not found'); + notFoundError.name = 'NotFoundException'; + + amplifyClientSendMock.mock.mockImplementationOnce(() => { + throw notFoundError; + }); + + const validator = new AppIdValidator(amplifyClientMock); + + await assert.rejects( + () => validator.validateAppId('invalid-app-id'), + (error) => { + assert(error instanceof AmplifyUserError); + assert.equal(error.name, 'InvalidAppIdError'); + assert(error.message.includes('invalid-app-id')); + assert(error.message.includes('test-region')); + return true; + } + ); + }); + + void it('throws AmplifyUserError when app ID does not exist (message check)', async () => { + const notFoundError = new Error('App not found for ID: invalid-app-id'); + + amplifyClientSendMock.mock.mockImplementationOnce(() => { + throw notFoundError; + }); + + const validator = new AppIdValidator(amplifyClientMock); + + await assert.rejects( + () => validator.validateAppId('invalid-app-id'), + (error) => { + assert(error instanceof AmplifyUserError); + assert.equal(error.name, 'InvalidAppIdError'); + return true; + } + ); + }); + + void it('re-throws other errors', async () => { + const otherError = new Error('Some other error'); + otherError.name = 'OtherError'; + + amplifyClientSendMock.mock.mockImplementationOnce(() => { + throw otherError; + }); + + const validator = new AppIdValidator(amplifyClientMock); + + await assert.rejects( + () => validator.validateAppId('valid-app-id'), + (error) => { + assert.equal(error.name, 'OtherError'); + assert.equal(error.message, 'Some other error'); + return true; + } + ); + }); +}); \ No newline at end of file diff --git a/packages/deployed-backend-client/src/app_id_validator.ts b/packages/deployed-backend-client/src/app_id_validator.ts new file mode 100644 index 00000000000..0bb8e358ec3 --- /dev/null +++ b/packages/deployed-backend-client/src/app_id_validator.ts @@ -0,0 +1,38 @@ +import { AmplifyClient, GetAppCommand } from '@aws-sdk/client-amplify'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; + +/** + * Validates if an Amplify App ID exists in the configured AWS account and region + */ +export class AppIdValidator { + /** + * Initialize with an Amplify client + */ + constructor(private readonly amplifyClient: AmplifyClient) {} + + /** + * Validates if the provided appId exists in the Amplify service + * @param appId The Amplify App ID to validate + * @throws AmplifyUserError if the appId doesn't exist + */ + validateAppId = async (appId: string): Promise => { + try { + await this.amplifyClient.send(new GetAppCommand({ appId })); + } catch (error) { + // Check if the error is because the app doesn't exist + if ( + error instanceof Error && + (error.name === 'NotFoundException' || + error.message.includes('App not found')) + ) { + const region = await this.amplifyClient.config.region(); + throw new AmplifyUserError('InvalidAppIdError', { + message: `The Amplify App ID '${appId}' does not exist in the configured region '${region}'`, + resolution: 'Please verify the App ID and ensure it exists in the configured AWS region', + }); + } + // Re-throw other errors + throw error; + } + }; +} \ No newline at end of file diff --git a/packages/deployed-backend-client/src/backend_output_client_factory.ts b/packages/deployed-backend-client/src/backend_output_client_factory.ts index b768ac72106..9fc27373b82 100644 --- a/packages/deployed-backend-client/src/backend_output_client_factory.ts +++ b/packages/deployed-backend-client/src/backend_output_client_factory.ts @@ -13,6 +13,7 @@ export enum BackendOutputClientErrorType { NO_STACK_FOUND = 'NoStackFound', CREDENTIALS_ERROR = 'CredentialsError', ACCESS_DENIED = 'AccessDenied', + INVALID_APP_ID = 'InvalidAppId', } /** * Error type for BackendOutputClientError diff --git a/packages/deployed-backend-client/src/backend_output_fetcher_factory.ts b/packages/deployed-backend-client/src/backend_output_fetcher_factory.ts index 5f14315300b..26ce297b001 100644 --- a/packages/deployed-backend-client/src/backend_output_fetcher_factory.ts +++ b/packages/deployed-backend-client/src/backend_output_fetcher_factory.ts @@ -61,7 +61,7 @@ export class BackendOutputFetcherFactory { } else if (isBackendIdentifier(backendIdentifier)) { return new StackMetadataBackendOutputRetrievalStrategy( this.cfnClient, - new BackendIdentifierMainStackNameResolver(backendIdentifier), + new BackendIdentifierMainStackNameResolver(backendIdentifier, this.amplifyClient), ); } return new StackMetadataBackendOutputRetrievalStrategy( diff --git a/packages/deployed-backend-client/src/index.ts b/packages/deployed-backend-client/src/index.ts index 54ab3207059..3907bc3a90d 100644 --- a/packages/deployed-backend-client/src/index.ts +++ b/packages/deployed-backend-client/src/index.ts @@ -1,5 +1,6 @@ export * from './deployed_backend_identifier.js'; export * from './backend_output_client_factory.js'; export * from './deployed_backend_client_factory.js'; +export * from './app_id_validator.js'; export type { AppNameAndBranchBackendIdentifier } from './stack-name-resolvers/app_name_and_branch_main_stack_name_resolver.js'; export type { StackIdentifier } from './stack-name-resolvers/passthrough_main_stack_name_resolver.js'; diff --git a/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.test.ts b/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.test.ts index 12537890902..af097aa5cab 100644 --- a/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.test.ts +++ b/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.test.ts @@ -2,6 +2,7 @@ import { describe, it } from 'node:test'; import { BackendIdentifierMainStackNameResolver } from './backend_identifier_main_stack_name_resolver.js'; import assert from 'node:assert'; import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { BackendIdentifierConversions } from '@aws-amplify/platform-core'; void describe('BackendIdentifierMainStackNameResolver', () => { void describe('resolveMainStackName', () => { @@ -22,5 +23,52 @@ void describe('BackendIdentifierMainStackNameResolver', () => { 'amplify-testBackendId-testBranchName-branch-e482a1c36f', ); }); + + void it('uses BackendIdentifierConversions.toStackName to convert backend ID to stack name', async () => { + const stackNameResolver = new BackendIdentifierMainStackNameResolver( + backendId, + ); + + const result = await stackNameResolver.resolveMainStackName(); + const expectedStackName = BackendIdentifierConversions.toStackName(backendId); + + assert.equal(result, expectedStackName); + }); + + void it('handles sandbox type correctly', async () => { + const sandboxBackendId: BackendIdentifier = { + namespace: 'testBackendId', + name: 'testSandboxName', + type: 'sandbox', + }; + + const stackNameResolver = new BackendIdentifierMainStackNameResolver( + sandboxBackendId, + ); + + const result = await stackNameResolver.resolveMainStackName(); + const expectedStackName = BackendIdentifierConversions.toStackName(sandboxBackendId); + + assert.equal(result, expectedStackName); + }); + + void it('handles backend IDs with hash correctly', async () => { + const backendIdWithHash: BackendIdentifier = { + namespace: 'testBackendId', + name: 'testBranchName', + type: 'branch', + hash: 'customHash', + }; + + const stackNameResolver = new BackendIdentifierMainStackNameResolver( + backendIdWithHash, + ); + + const result = await stackNameResolver.resolveMainStackName(); + const expectedStackName = BackendIdentifierConversions.toStackName(backendIdWithHash); + + assert.equal(result, expectedStackName); + assert(result.endsWith('-customHash')); + }); }); }); diff --git a/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.ts b/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.ts index 584c84c972e..9b17fed502c 100644 --- a/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.ts +++ b/packages/deployed-backend-client/src/stack-name-resolvers/backend_identifier_main_stack_name_resolver.ts @@ -3,6 +3,8 @@ import { MainStackNameResolver, } from '@aws-amplify/plugin-types'; import { BackendIdentifierConversions } from '@aws-amplify/platform-core'; +import { AmplifyClient, GetAppCommand, ResourceNotFoundException } from '@aws-sdk/client-amplify'; +import { BackendOutputClientError, BackendOutputClientErrorType } from '../backend_output_client_factory.js'; /** * Resolves the main stack name for a given project environment @@ -11,13 +13,35 @@ export class BackendIdentifierMainStackNameResolver implements MainStackNameResolver { /** - * Initialize with the project environment identifier and an SSMClient + * Initialize with the project environment identifier and an AmplifyClient */ - constructor(private readonly backendId: BackendIdentifier) {} + constructor( + private readonly backendId: BackendIdentifier, + private readonly amplifyClient?: AmplifyClient + ) {} /** * Resolve the stack name for this project environment */ - resolveMainStackName = async (): Promise => - BackendIdentifierConversions.toStackName(this.backendId); + resolveMainStackName = async (): Promise => { + // If the backendId has a namespace (which is the appId for branch type), validate it exists + if (this.backendId.type === 'branch' && this.amplifyClient) { + try { + await this.amplifyClient.send( + new GetAppCommand({ appId: this.backendId.namespace }) + ); + } catch (error) { + if (error instanceof ResourceNotFoundException) { + const region = await this.amplifyClient.config.region(); + throw new BackendOutputClientError( + BackendOutputClientErrorType.INVALID_APP_ID, + `App with ID '${this.backendId.namespace}' does not exist in region ${region}. Please check the App ID and region configuration.` + ); + } + throw error; + } + } + + return BackendIdentifierConversions.toStackName(this.backendId); + }; } diff --git a/packages/model-generator/src/get_backend_output_with_error_handling.ts b/packages/model-generator/src/get_backend_output_with_error_handling.ts index f4a136c8eb2..66d3e49d0b2 100644 --- a/packages/model-generator/src/get_backend_output_with_error_handling.ts +++ b/packages/model-generator/src/get_backend_output_with_error_handling.ts @@ -69,6 +69,16 @@ export const getBackendOutputWithErrorHandling = async ( }, error, ); + case BackendOutputClientErrorType.INVALID_APP_ID: + throw new AmplifyUserError( + 'InvalidAppIdError', + { + message: error.message, + resolution: + 'Ensure the Amplify App ID specified is correct and exists in the configured region.', + }, + error, + ); default: throw error; }