Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,14 @@ describe('AmplifyGen2MigrationValidations', () => {
],
NextToken: undefined,
});
// GetTemplate response — DeletionPolicy is Delete
mockCfnSend.mockResolvedValueOnce({
TemplateBody: JSON.stringify({
Resources: {
Table: { Type: 'AWS::DynamoDB::Table', DeletionPolicy: 'Delete' },
},
}),
});

const changeSet: DescribeChangeSetOutput = {
Changes: [
Expand All @@ -378,6 +386,82 @@ describe('AmplifyGen2MigrationValidations', () => {
});
});

it('should pass when nested DynamoDB table has DeletionPolicy Retain', async () => {
mockCfnSend.mockResolvedValueOnce({
StackResourceSummaries: [
{
ResourceType: 'AWS::DynamoDB::Table',
PhysicalResourceId: 'MyTable',
LogicalResourceId: 'Table',
},
],
NextToken: undefined,
});
// GetTemplate response — DeletionPolicy is Retain
mockCfnSend.mockResolvedValueOnce({
TemplateBody: JSON.stringify({
Resources: {
Table: { Type: 'AWS::DynamoDB::Table', DeletionPolicy: 'Retain' },
},
}),
});

const changeSet: DescribeChangeSetOutput = {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'ApiStack',
PhysicalResourceId: 'api-stack',
},
},
],
};

await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow();
});

it('should throw when nested DynamoDB table has no DeletionPolicy', async () => {
mockCfnSend.mockResolvedValueOnce({
StackResourceSummaries: [
{
ResourceType: 'AWS::DynamoDB::Table',
PhysicalResourceId: 'MyTable',
LogicalResourceId: 'Table',
},
],
NextToken: undefined,
});
// GetTemplate response — no DeletionPolicy set
mockCfnSend.mockResolvedValueOnce({
TemplateBody: JSON.stringify({
Resources: {
Table: { Type: 'AWS::DynamoDB::Table' },
},
}),
});

const changeSet: DescribeChangeSetOutput = {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'ApiStack',
PhysicalResourceId: 'api-stack',
},
},
],
};

await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
name: 'DestructiveMigrationError',
});
});

it('should pass when nested stack contains only stateless resources', async () => {
mockCfnSend.mockResolvedValueOnce({
StackResourceSummaries: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AmplifyMigrationLockStep } from '../../../commands/gen2-migration/lock';
import { $TSContext } from '@aws-amplify/amplify-cli-core';
import { SetStackPolicyCommand } from '@aws-sdk/client-cloudformation';
import { CreateChangeSetCommand, DeleteChangeSetCommand, SetStackPolicyCommand } from '@aws-sdk/client-cloudformation';
import { UpdateAppCommand } from '@aws-sdk/client-amplify';
import { SpinningLogger } from '../../../commands/gen2-migration/_infra/spinning-logger';
import { Gen1App } from '../../../commands/gen2-migration/generate/_infra/gen1-app';
Expand All @@ -14,6 +14,11 @@ jest.mock('@aws-sdk/client-appsync', () => ({
},
})),
}));
jest.mock('@aws-sdk/client-cloudformation', () => ({
...jest.requireActual('@aws-sdk/client-cloudformation'),
waitUntilChangeSetCreateComplete: jest.fn().mockResolvedValue({}),
waitUntilStackUpdateComplete: jest.fn().mockResolvedValue({}),
}));
jest.mock('@aws-sdk/client-dynamodb', () => ({
...jest.requireActual('@aws-sdk/client-dynamodb'),
paginateListTables: jest.fn().mockImplementation(() => ({
Expand Down Expand Up @@ -78,7 +83,10 @@ describe('AmplifyMigrationLockStep', () => {

describe('forward stack policy merge', () => {
it('should append lock statement to empty stack policy', async () => {
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined }).mockResolvedValueOnce({});
mockCfnSend
.mockResolvedValueOnce({ StackResources: [] }) // DescribeStackResources for DeletionPolicy operation
.mockResolvedValueOnce({ StackPolicyBody: undefined })
.mockResolvedValueOnce({});
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
Expand All @@ -98,7 +106,10 @@ describe('AmplifyMigrationLockStep', () => {
const existingPolicy = {
Statement: [{ Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' }],
};
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(existingPolicy) }).mockResolvedValueOnce({});
mockCfnSend
.mockResolvedValueOnce({ StackResources: [] }) // DescribeStackResources for DeletionPolicy operation
.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(existingPolicy) })
.mockResolvedValueOnce({});
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
Expand All @@ -121,7 +132,9 @@ describe('AmplifyMigrationLockStep', () => {
const alreadyLockedPolicy = {
Statement: [{ Effect: 'Deny', Action: 'Update:*', Principal: '*', Resource: '*' }],
};
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(alreadyLockedPolicy) });
mockCfnSend
.mockResolvedValueOnce({ StackResources: [] }) // DescribeStackResources for DeletionPolicy operation
.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(alreadyLockedPolicy) });
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
Expand All @@ -134,7 +147,10 @@ describe('AmplifyMigrationLockStep', () => {

describe('forward env var merge', () => {
it('should merge new env var with existing env vars', async () => {
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined }).mockResolvedValueOnce({});
mockCfnSend
.mockResolvedValueOnce({ StackResources: [] }) // DescribeStackResources for DeletionPolicy operation
.mockResolvedValueOnce({ StackPolicyBody: undefined })
.mockResolvedValueOnce({});
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: { EXISTING: 'value' } } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
Expand Down Expand Up @@ -247,4 +263,138 @@ describe('AmplifyMigrationLockStep', () => {
});
});
});

describe('forward DeletionPolicy changeset validation', () => {
const modelTemplate = {
Resources: {
TodoTable: { Type: 'AWS::DynamoDB::Table', Properties: {} },
},
};

function setupApiStackMocks() {
// DescribeStackResources — root stack has one API nested stack
mockCfnSend.mockResolvedValueOnce({
StackResources: [
{
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'apitestapi',
PhysicalResourceId: 'arn:aws:cloudformation:us-east-1:123:stack/api-stack/abc',
},
],
});
// ListStackResources — API stack has one model nested stack
mockCfnSend.mockResolvedValueOnce({
StackResourceSummaries: [
{
ResourceType: 'AWS::CloudFormation::Stack',
PhysicalResourceId: 'arn:aws:cloudformation:us-east-1:123:stack/model-stack/def',
},
],
});
// GetTemplate — model stack template with DynamoDB table (no Retain)
mockCfnSend.mockResolvedValueOnce({
TemplateBody: JSON.stringify(modelTemplate),
});
// DescribeStacks — model stack parameters
mockCfnSend.mockResolvedValueOnce({
Stacks: [{ Parameters: [{ ParameterKey: 'env', ParameterValue: 'testEnv' }] }],
});
// CreateChangeSet
mockCfnSend.mockResolvedValueOnce({});
}

it('should validate and proceed when only DynamoDB and IAM Policy Modify changes', async () => {
setupApiStackMocks();
// DescribeChangeSet — Modify on DynamoDB table + IAM policy (expected side effect)
mockCfnSend.mockResolvedValueOnce({
Changes: [
{ ResourceChange: { Action: 'Modify', ResourceType: 'AWS::DynamoDB::Table', LogicalResourceId: 'TodoTable' } },
{ ResourceChange: { Action: 'Modify', ResourceType: 'AWS::IAM::Policy', LogicalResourceId: 'TodoIAMRoleDefaultPolicy' } },
],
});
// DeleteChangeSet (cleanup)
mockCfnSend.mockResolvedValueOnce({});
// UpdateStack
mockCfnSend.mockResolvedValueOnce({});
// GetStackPolicy + SetStackPolicy for lock
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined });
mockCfnSend.mockResolvedValueOnce({});
// Amplify env var
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
await plan.execute();

const createCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof CreateChangeSetCommand);
expect(createCalls).toHaveLength(1);
const deleteCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof DeleteChangeSetCommand);
expect(deleteCalls).toHaveLength(1);
});

it('should abort when changeset contains Add action', async () => {
setupApiStackMocks();
// DescribeChangeSet — unexpected Lambda change
mockCfnSend.mockResolvedValueOnce({
Changes: [{ ResourceChange: { Action: 'Add', ResourceType: 'AWS::Lambda::Function', LogicalResourceId: 'NewFunction' } }],
});
// DeleteChangeSet (cleanup in validation)
mockCfnSend.mockResolvedValueOnce({});
// GetStackPolicy + SetStackPolicy for lock (still runs after error is caught by runner)
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined });
mockCfnSend.mockResolvedValueOnce({});
// Amplify env var
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
await expect(plan.execute()).rejects.toMatchObject({
name: 'MigrationError',
message: expect.stringContaining('unexpected changes'),
});
});

it('should abort when changeset contains Remove action on DynamoDB', async () => {
setupApiStackMocks();
// DescribeChangeSet — Remove on DynamoDB table
mockCfnSend.mockResolvedValueOnce({
Changes: [{ ResourceChange: { Action: 'Remove', ResourceType: 'AWS::DynamoDB::Table', LogicalResourceId: 'TodoTable' } }],
});
// DeleteChangeSet (cleanup in validation)
mockCfnSend.mockResolvedValueOnce({});
// GetStackPolicy + SetStackPolicy
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined });
mockCfnSend.mockResolvedValueOnce({});
// Amplify env var
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
await expect(plan.execute()).rejects.toMatchObject({
name: 'MigrationError',
message: expect.stringContaining('unexpected changes'),
});
});

it('should abort when changeset contains Modify on unexpected resource type', async () => {
setupApiStackMocks();
// DescribeChangeSet — Modify on AppSync resolver (not in allowed set)
mockCfnSend.mockResolvedValueOnce({
Changes: [
{ ResourceChange: { Action: 'Modify', ResourceType: 'AWS::DynamoDB::Table', LogicalResourceId: 'TodoTable' } },
{ ResourceChange: { Action: 'Modify', ResourceType: 'AWS::AppSync::Resolver', LogicalResourceId: 'GetTodoResolver' } },
],
});
// DeleteChangeSet (cleanup in validation)
mockCfnSend.mockResolvedValueOnce({});
// GetStackPolicy + SetStackPolicy
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined });
mockCfnSend.mockResolvedValueOnce({});
// Amplify env var
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});

const plan = await lockStep.forward();
await expect(plan.execute()).rejects.toMatchObject({
name: 'MigrationError',
message: expect.stringContaining('unexpected changes'),
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DescribeStacksCommand,
ListStackResourcesCommand,
GetStackPolicyCommand,
GetTemplateCommand,
} from '@aws-sdk/client-cloudformation';
import { STATEFUL_RESOURCES } from './stateful-resources';
import CLITable from 'cli-table3';
Expand Down Expand Up @@ -204,6 +205,13 @@ export class AmplifyGen2MigrationValidations {
logicalId: resource.LogicalResourceId,
});
} else if (resource.ResourceType && STATEFUL_RESOURCES.has(resource.ResourceType)) {
if (resource.ResourceType === 'AWS::DynamoDB::Table') {
const templateResponse = await this.gen1App.clients.cloudFormation.send(new GetTemplateCommand({ StackName: stackName }));
const template = JSON.parse(templateResponse.TemplateBody);
if (template.Resources[resource.LogicalResourceId].DeletionPolicy === 'Retain') {
continue;
}
}
const category = parentCategory || extractCategory(resource.LogicalResourceId || '');
const physicalId = resource.PhysicalResourceId || 'N/A';
this.logger.info(`Scanning '${category}' category: found stateful resource "${physicalId}"`);
Expand Down
Loading
Loading