Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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;
}
);
});
});
});
});
19 changes: 17 additions & 2 deletions packages/cli/src/backend-identifier/backend_identifier_resolver.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<DeployedBackendIdentifier | undefined> => {
// 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) {
Expand All @@ -46,9 +55,15 @@ export class AppBackendIdentifierResolver implements BackendIdentifierResolver {
}
return undefined;
};

resolveBackendIdentifier = async (
args: BackendIdentifierParameters,
): Promise<BackendIdentifier | undefined> => {
// 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
);

Expand Down
86 changes: 86 additions & 0 deletions packages/deployed-backend-client/src/app_id_validator.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
);
});
});
38 changes: 38 additions & 0 deletions packages/deployed-backend-client/src/app_id_validator.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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;
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading