Skip to content
Closed
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
@@ -0,0 +1,125 @@
import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_validations';
import { $TSContext } from '@aws-amplify/amplify-cli-core';
import { Logger } from '../../../commands/gen2-migration';

jest.mock('@aws-sdk/client-cloudformation');
jest.mock('bottleneck', () => {
return jest.fn().mockImplementation(() => ({
schedule: jest.fn((fn) => fn()),
}));
});

const mockSyncCloudBackendFromS3 = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../commands/drift-detection/services', () => ({
CloudFormationService: jest.fn().mockImplementation(() => ({
syncCloudBackendFromS3: mockSyncCloudBackendFromS3,
getClient: mockGetClient,
})),
}));

const mockDetectTemplateDrift = jest.fn();
jest.mock('../../../commands/drift-detection/detect-template-drift', () => ({
detectTemplateDrift: (...args: any[]) => mockDetectTemplateDrift(...args),
}));

describe('AmplifyGen2MigrationValidations - validateTemplateDrift', () => {
let mockContext: $TSContext;
let validations: AmplifyGen2MigrationValidations;
const mockCfnClient = {};

beforeEach(() => {
mockContext = {} as $TSContext;
validations = new AmplifyGen2MigrationValidations(new Logger('mock', 'mock', 'mock'), 'amplify-test-stack', 'dev', mockContext);
mockGetClient.mockResolvedValue(mockCfnClient);
jest.clearAllMocks();
});

it('should pass when no template drift is detected', async () => {
mockSyncCloudBackendFromS3.mockResolvedValue(true);
mockDetectTemplateDrift.mockResolvedValue({
changes: [],
skipped: false,
});

await expect(validations.validateTemplateDrift()).resolves.not.toThrow();
expect(mockSyncCloudBackendFromS3).toHaveBeenCalledWith(mockContext);
expect(mockDetectTemplateDrift).toHaveBeenCalledWith('amplify-test-stack', expect.any(Object), mockCfnClient);
});

it('should throw MigrationError when template drift is detected', async () => {
mockSyncCloudBackendFromS3.mockResolvedValue(true);
mockDetectTemplateDrift.mockResolvedValue({
changes: [{ LogicalResourceId: 'SomeResource', Action: 'Modify' }],
skipped: false,
});

await expect(validations.validateTemplateDrift()).rejects.toMatchObject({
name: 'MigrationError',
message: 'Template drift detected',
});
});

it('should throw MigrationError when S3 sync fails', async () => {
mockSyncCloudBackendFromS3.mockResolvedValue(false);

await expect(validations.validateTemplateDrift()).rejects.toMatchObject({
name: 'MigrationError',
message: 'Failed to sync cloud backend from S3',
resolution: 'Ensure the project is deployed and S3 bucket is accessible.',
});
expect(mockDetectTemplateDrift).not.toHaveBeenCalled();
});

it('should throw MigrationError when template drift detection is skipped', async () => {
mockSyncCloudBackendFromS3.mockResolvedValue(true);
mockDetectTemplateDrift.mockResolvedValue({
changes: [],
skipped: true,
skipReason: 'No cached CloudFormation template found',
});

await expect(validations.validateTemplateDrift()).rejects.toMatchObject({
name: 'MigrationError',
message: 'Template drift detection was skipped: No cached CloudFormation template found',
});
});

it('should throw MigrationError when detection is skipped due to nested stack errors', async () => {
mockSyncCloudBackendFromS3.mockResolvedValue(true);
mockDetectTemplateDrift.mockResolvedValue({
changes: [],
skipped: true,
skipReason: 'One or more nested stacks could not be analyzed',
});

await expect(validations.validateTemplateDrift()).rejects.toMatchObject({
name: 'MigrationError',
message: 'Template drift detection was skipped: One or more nested stacks could not be analyzed',
});
});

it('should pass the correct stack name and CFN client to detectTemplateDrift', async () => {
mockSyncCloudBackendFromS3.mockResolvedValue(true);
mockDetectTemplateDrift.mockResolvedValue({
changes: [],
skipped: false,
});

await validations.validateTemplateDrift();

expect(mockGetClient).toHaveBeenCalledWith(mockContext);
expect(mockDetectTemplateDrift).toHaveBeenCalledWith(
'amplify-test-stack',
expect.objectContaining({
info: expect.any(Function),
debug: expect.any(Function),
warn: expect.any(Function),
blankLine: expect.any(Function),
success: expect.any(Function),
error: expect.any(Function),
}),
mockCfnClient,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { AmplifyMigrationLockStep } from '../../../commands/gen2-migration/lock';
import { $TSContext } from '@aws-amplify/amplify-cli-core';
import { Logger } from '../../../commands/gen2-migration';

const mockValidateDeploymentStatus = jest.fn();
const mockValidateLockStatus = jest.fn();
const mockValidateTemplateDrift = jest.fn();

jest.mock('../../../commands/gen2-migration/_validations', () => ({
AmplifyGen2MigrationValidations: jest.fn().mockImplementation(() => ({
validateDeploymentStatus: mockValidateDeploymentStatus,
validateLockStatus: mockValidateLockStatus,
validateTemplateDrift: mockValidateTemplateDrift,
validateDrift: jest.fn(),
})),
}));

jest.mock('@aws-sdk/client-cloudformation');
jest.mock('@aws-sdk/client-amplify');
jest.mock('@aws-sdk/client-dynamodb');
jest.mock('@aws-sdk/client-appsync');
jest.mock('@aws-sdk/client-cognito-identity-provider');
jest.mock('@aws-amplify/amplify-cli-core', () => ({
...jest.requireActual('@aws-amplify/amplify-cli-core'),
stateManager: {
getMeta: jest.fn().mockReturnValue({}),
},
}));

describe('AmplifyMigrationLockStep - rollbackValidate', () => {
let step: AmplifyMigrationLockStep;
let mockContext: $TSContext;

beforeEach(() => {
mockContext = {} as $TSContext;
step = new AmplifyMigrationLockStep(
new Logger('lock', 'test-app', 'dev'),
'dev',
'test-app',
'test-app-id',
'amplify-test-stack',
'us-east-1',
mockContext,
);
jest.clearAllMocks();
});

it('should call all three validations in order', async () => {
mockValidateDeploymentStatus.mockResolvedValue(undefined);
mockValidateLockStatus.mockResolvedValue(undefined);
mockValidateTemplateDrift.mockResolvedValue(undefined);

await step.rollbackValidate();

expect(mockValidateDeploymentStatus).toHaveBeenCalledTimes(1);
expect(mockValidateLockStatus).toHaveBeenCalledTimes(1);
expect(mockValidateTemplateDrift).toHaveBeenCalledTimes(1);
});

it('should propagate error when validateDeploymentStatus fails', async () => {
const error = new Error('Stack not found');
error.name = 'StackNotFoundError';
mockValidateDeploymentStatus.mockRejectedValue(error);

await expect(step.rollbackValidate()).rejects.toThrow('Stack not found');
expect(mockValidateLockStatus).not.toHaveBeenCalled();
expect(mockValidateTemplateDrift).not.toHaveBeenCalled();
});

it('should propagate error when validateLockStatus fails', async () => {
mockValidateDeploymentStatus.mockResolvedValue(undefined);
const error = new Error('Stack is not locked');
error.name = 'MigrationError';
mockValidateLockStatus.mockRejectedValue(error);

await expect(step.rollbackValidate()).rejects.toThrow('Stack is not locked');
expect(mockValidateDeploymentStatus).toHaveBeenCalledTimes(1);
expect(mockValidateTemplateDrift).not.toHaveBeenCalled();
});

it('should propagate error when validateTemplateDrift fails', async () => {
mockValidateDeploymentStatus.mockResolvedValue(undefined);
mockValidateLockStatus.mockResolvedValue(undefined);
const error = new Error('Template drift detected');
error.name = 'MigrationError';
mockValidateTemplateDrift.mockRejectedValue(error);

await expect(step.rollbackValidate()).rejects.toThrow('Template drift detected');
expect(mockValidateDeploymentStatus).toHaveBeenCalledTimes(1);
expect(mockValidateLockStatus).toHaveBeenCalledTimes(1);
});

it('should pass when all validations succeed with no drift', async () => {
mockValidateDeploymentStatus.mockResolvedValue(undefined);
mockValidateLockStatus.mockResolvedValue(undefined);
mockValidateTemplateDrift.mockResolvedValue(undefined);

await expect(step.rollbackValidate()).resolves.not.toThrow();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { Logger } from '../gen2-migration';
import chalk from 'chalk';
import { printer } from '@aws-amplify/amplify-prompts';
import { extractCategory } from './categories';
import { detectTemplateDrift } from '../drift-detection/detect-template-drift';
import { CloudFormationService } from '../drift-detection/services';

export class AmplifyGen2MigrationValidations {
private limiter = new Bottleneck({
Expand Down Expand Up @@ -192,6 +194,39 @@ export class AmplifyGen2MigrationValidations {
this.logger.info(chalk.green(`Stack ${this.rootStackName} is locked ✔`));
}

public async validateTemplateDrift(): Promise<void> {
this.logger.info('Checking for template drift...');

const cfnService = new CloudFormationService(printer);
const syncSuccess = await cfnService.syncCloudBackendFromS3(this.context);
if (!syncSuccess) {
throw new AmplifyError('MigrationError', {
message: 'Failed to sync cloud backend from S3',
resolution: 'Ensure the project is deployed and S3 bucket is accessible.',
});
}

const cfn = await cfnService.getClient(this.context);
const results = await detectTemplateDrift(this.rootStackName, printer, cfn);

if (results.skipped) {
throw new AmplifyError('MigrationError', {
message: `Template drift detection was skipped: ${results.skipReason}`,
resolution: 'Ensure the project is deployed and templates are available.',
});
}

if (results.changes.length > 0) {
throw new AmplifyError('MigrationError', {
message: 'Template drift detected',
resolution:
'The CloudFormation templates have changed since the lock was applied. This may indicate that a refactor has already been performed. Resolve the drift before rolling back.',
});
}

this.logger.info(chalk.green('No template drift detected ✔'));
}

private async getStatefulResources(
stackName: string,
parentLogicalId?: string,
Expand Down
6 changes: 4 additions & 2 deletions packages/amplify-cli/src/commands/gen2-migration/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep {
}

public async rollbackValidate(): Promise<void> {
// https://github.com/aws-amplify/amplify-cli/issues/14570
return;
const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context);
await validations.validateDeploymentStatus();
await validations.validateLockStatus();
await validations.validateTemplateDrift();
}

public async execute(): Promise<AmplifyMigrationOperation[]> {
Expand Down