Skip to content

Commit c0e2c09

Browse files
sai-rayiankhou
authored andcommitted
feat(gen2-migration): preserve existing stack policies in lock step (#14648)
* chore: update lock execute and rollback * chore: add unit tests * chore: update tests * chore: address lock idempotency issue * chore: update lock tests --------- Co-authored-by: Sai Ray <saisujit@amazon.com>
1 parent bb9842c commit c0e2c09

File tree

2 files changed

+322
-29
lines changed
  • packages/amplify-cli/src

2 files changed

+322
-29
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { AmplifyMigrationLockStep } from '../../../commands/gen2-migration/lock';
2+
import { $TSContext } from '@aws-amplify/amplify-cli-core';
3+
import { CloudFormationClient, SetStackPolicyCommand } from '@aws-sdk/client-cloudformation';
4+
import { AmplifyClient, UpdateAppCommand } from '@aws-sdk/client-amplify';
5+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
6+
import { SpinningLogger } from '../../../commands/gen2-migration/_spinning-logger';
7+
8+
jest.mock('@aws-sdk/client-cloudformation', () => ({
9+
...jest.requireActual('@aws-sdk/client-cloudformation'),
10+
CloudFormationClient: jest.fn(),
11+
}));
12+
jest.mock('@aws-sdk/client-amplify', () => ({
13+
...jest.requireActual('@aws-sdk/client-amplify'),
14+
AmplifyClient: jest.fn(),
15+
}));
16+
jest.mock('@aws-sdk/client-appsync', () => ({
17+
...jest.requireActual('@aws-sdk/client-appsync'),
18+
AppSyncClient: jest.fn().mockImplementation(() => ({ send: jest.fn() })),
19+
paginateListGraphqlApis: jest.fn().mockImplementation(() => ({
20+
[Symbol.asyncIterator]: async function* () {
21+
yield { graphqlApis: [{ name: 'testApp-testEnv', apiId: 'test-api-id' }] };
22+
},
23+
})),
24+
}));
25+
jest.mock('@aws-sdk/client-dynamodb', () => ({
26+
...jest.requireActual('@aws-sdk/client-dynamodb'),
27+
DynamoDBClient: jest.fn().mockImplementation(() => ({ send: jest.fn() })),
28+
paginateListTables: jest.fn().mockImplementation(() => ({
29+
[Symbol.asyncIterator]: async function* () {
30+
yield { TableNames: ['Table1-test-api-id-testEnv', 'Table2-test-api-id-testEnv'] };
31+
},
32+
})),
33+
}));
34+
jest.mock('@aws-amplify/amplify-prompts', () => ({
35+
printer: { info: jest.fn(), blankLine: jest.fn(), success: jest.fn(), warn: jest.fn(), debug: jest.fn() },
36+
AmplifySpinner: jest.fn().mockImplementation(() => ({
37+
start: jest.fn(),
38+
stop: jest.fn(),
39+
resetMessage: jest.fn(),
40+
})),
41+
isDebug: false,
42+
}));
43+
jest.mock('../../../commands/gen2-migration/_validations', () => ({
44+
AmplifyGen2MigrationValidations: jest.fn().mockImplementation(() => ({
45+
validateDeploymentStatus: jest.fn().mockResolvedValue(undefined),
46+
validateDrift: jest.fn().mockResolvedValue(undefined),
47+
})),
48+
}));
49+
50+
describe('AmplifyMigrationLockStep', () => {
51+
let lockStep: AmplifyMigrationLockStep;
52+
let mockCfnSend: jest.Mock;
53+
let mockAmplifySend: jest.Mock;
54+
let mockLogger: SpinningLogger;
55+
56+
beforeEach(() => {
57+
mockCfnSend = jest.fn();
58+
mockAmplifySend = jest.fn();
59+
60+
(CloudFormationClient as jest.Mock).mockImplementation(() => ({ send: mockCfnSend }));
61+
(AmplifyClient as jest.Mock).mockImplementation(() => ({ send: mockAmplifySend }));
62+
(DynamoDBClient as jest.Mock).mockImplementation(() => ({ send: jest.fn() }));
63+
64+
mockLogger = new SpinningLogger('mock');
65+
jest.spyOn(mockLogger, 'info').mockImplementation(() => {});
66+
jest.spyOn(mockLogger, 'start').mockImplementation(() => {});
67+
jest.spyOn(mockLogger, 'succeed').mockImplementation(() => {});
68+
jest.spyOn(mockLogger, 'push').mockImplementation(() => {});
69+
jest.spyOn(mockLogger, 'pop').mockImplementation(() => {});
70+
71+
lockStep = new AmplifyMigrationLockStep(
72+
mockLogger,
73+
'testEnv',
74+
'testApp',
75+
'test-app-id',
76+
'test-root-stack',
77+
'us-east-1',
78+
{} as $TSContext,
79+
);
80+
});
81+
82+
afterEach(() => {
83+
jest.clearAllMocks();
84+
});
85+
86+
describe('forward stack policy merge', () => {
87+
it('should append lock statement to empty stack policy', async () => {
88+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined }).mockResolvedValueOnce({});
89+
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});
90+
91+
const plan = await lockStep.forward();
92+
await plan.execute();
93+
94+
const setCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof SetStackPolicyCommand);
95+
expect(setCalls).toHaveLength(1);
96+
expect(setCalls[0][0].input).toEqual({
97+
StackName: 'test-root-stack',
98+
StackPolicyBody: JSON.stringify({
99+
Statement: [{ Effect: 'Deny', Action: 'Update:*', Principal: '*', Resource: '*' }],
100+
}),
101+
});
102+
});
103+
104+
it('should append lock statement preserving existing statements', async () => {
105+
const existingPolicy = {
106+
Statement: [{ Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' }],
107+
};
108+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(existingPolicy) }).mockResolvedValueOnce({});
109+
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});
110+
111+
const plan = await lockStep.forward();
112+
await plan.execute();
113+
114+
const setCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof SetStackPolicyCommand);
115+
expect(setCalls).toHaveLength(1);
116+
expect(setCalls[0][0].input).toEqual({
117+
StackName: 'test-root-stack',
118+
StackPolicyBody: JSON.stringify({
119+
Statement: [
120+
{ Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' },
121+
{ Effect: 'Deny', Action: 'Update:*', Principal: '*', Resource: '*' },
122+
],
123+
}),
124+
});
125+
});
126+
127+
it('should skip SetStackPolicy when lock statement already exists', async () => {
128+
const alreadyLockedPolicy = {
129+
Statement: [{ Effect: 'Deny', Action: 'Update:*', Principal: '*', Resource: '*' }],
130+
};
131+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(alreadyLockedPolicy) });
132+
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: {} } }).mockResolvedValueOnce({});
133+
134+
const plan = await lockStep.forward();
135+
await plan.execute();
136+
137+
const setCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof SetStackPolicyCommand);
138+
expect(setCalls).toHaveLength(0);
139+
});
140+
});
141+
142+
describe('forward env var merge', () => {
143+
it('should merge new env var with existing env vars', async () => {
144+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined }).mockResolvedValueOnce({});
145+
mockAmplifySend.mockResolvedValueOnce({ app: { environmentVariables: { EXISTING: 'value' } } }).mockResolvedValueOnce({});
146+
147+
const plan = await lockStep.forward();
148+
await plan.execute();
149+
150+
const updateCalls = mockAmplifySend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof UpdateAppCommand);
151+
expect(updateCalls).toHaveLength(1);
152+
expect(updateCalls[0][0].input).toEqual({
153+
appId: 'test-app-id',
154+
environmentVariables: { EXISTING: 'value', GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv' },
155+
});
156+
});
157+
});
158+
159+
describe('rollback stack policy removal', () => {
160+
it('should remove lock statement and preserve customer statements', async () => {
161+
const policyWithLock = {
162+
Statement: [
163+
{ Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' },
164+
{ Effect: 'Deny', Action: 'Update:*', Principal: '*', Resource: '*' },
165+
],
166+
};
167+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(policyWithLock) }).mockResolvedValueOnce({});
168+
mockAmplifySend
169+
.mockResolvedValueOnce({ app: { environmentVariables: { GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv' } } })
170+
.mockResolvedValueOnce({});
171+
172+
const plan = await lockStep.rollback();
173+
await plan.execute();
174+
175+
const setCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof SetStackPolicyCommand);
176+
expect(setCalls).toHaveLength(1);
177+
expect(setCalls[0][0].input).toEqual({
178+
StackName: 'test-root-stack',
179+
StackPolicyBody: JSON.stringify({
180+
Statement: [{ Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' }],
181+
}),
182+
});
183+
});
184+
185+
it('should set allow-all when lock statement was the only one', async () => {
186+
const policyWithOnlyLock = {
187+
Statement: [{ Effect: 'Deny', Action: 'Update:*', Principal: '*', Resource: '*' }],
188+
};
189+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(policyWithOnlyLock) }).mockResolvedValueOnce({});
190+
mockAmplifySend
191+
.mockResolvedValueOnce({ app: { environmentVariables: { GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv' } } })
192+
.mockResolvedValueOnce({});
193+
194+
const plan = await lockStep.rollback();
195+
await plan.execute();
196+
197+
const setCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof SetStackPolicyCommand);
198+
expect(setCalls).toHaveLength(1);
199+
expect(setCalls[0][0].input).toEqual({
200+
StackName: 'test-root-stack',
201+
StackPolicyBody: JSON.stringify({
202+
Statement: [{ Effect: 'Allow', Action: 'Update:*', Principal: '*', Resource: '*' }],
203+
}),
204+
});
205+
});
206+
207+
it('should skip SetStackPolicy when no existing policy (lock not found)', async () => {
208+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined });
209+
mockAmplifySend
210+
.mockResolvedValueOnce({ app: { environmentVariables: { GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv' } } })
211+
.mockResolvedValueOnce({});
212+
213+
const plan = await lockStep.rollback();
214+
await plan.execute();
215+
216+
const setCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof SetStackPolicyCommand);
217+
expect(setCalls).toHaveLength(0);
218+
});
219+
220+
it('should skip SetStackPolicy when lock statement is not found', async () => {
221+
const customerPolicy = {
222+
Statement: [{ Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' }],
223+
};
224+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(customerPolicy) });
225+
mockAmplifySend
226+
.mockResolvedValueOnce({ app: { environmentVariables: { GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv' } } })
227+
.mockResolvedValueOnce({});
228+
229+
const plan = await lockStep.rollback();
230+
await plan.execute();
231+
232+
const setCalls = mockCfnSend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof SetStackPolicyCommand);
233+
expect(setCalls).toHaveLength(0);
234+
});
235+
});
236+
237+
describe('rollback env var removal', () => {
238+
it('should remove GEN2_MIGRATION_ENVIRONMENT_NAME and preserve other env vars', async () => {
239+
mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined });
240+
mockAmplifySend
241+
.mockResolvedValueOnce({
242+
app: { environmentVariables: { GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv', OTHER: 'keep' } },
243+
})
244+
.mockResolvedValueOnce({});
245+
246+
const plan = await lockStep.rollback();
247+
await plan.execute();
248+
249+
const updateCalls = mockAmplifySend.mock.calls.filter(([cmd]: [unknown]) => cmd instanceof UpdateAppCommand);
250+
expect(updateCalls).toHaveLength(1);
251+
expect(updateCalls[0][0].input).toEqual({
252+
appId: 'test-app-id',
253+
environmentVariables: { OTHER: 'keep' },
254+
});
255+
});
256+
});
257+
});

0 commit comments

Comments
 (0)