diff --git a/.projen/deps.json b/.projen/deps.json index 94f5543..2fe8b93 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -23,6 +23,10 @@ "version": "^8", "type": "build" }, + { + "name": "aws-sdk-client-mock", + "type": "build" + }, { "name": "commit-and-tag-version", "version": "^12", diff --git a/.projen/tasks.json b/.projen/tasks.json index 17b60ac..ff84307 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -230,13 +230,13 @@ }, "steps": [ { - "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@types/jest,@types/node,eslint-import-resolver-typescript,eslint-plugin-import,jest,projen,ts-jest,ts-node,typescript,@aws-cdk/cdk-assets-lib,@aws-sdk/client-cloudformation,@aws-sdk/client-organizations,@aws-sdk/client-s3,@aws-sdk/client-ssm,@aws-sdk/client-sso-admin,@aws-sdk/client-sts,@aws-sdk/credential-providers,clipanion,liquidjs,zip-lib" + "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@types/jest,@types/node,aws-sdk-client-mock,eslint-import-resolver-typescript,eslint-plugin-import,jest,projen,ts-jest,ts-node,typescript,@aws-cdk/cdk-assets-lib,@aws-sdk/client-cloudformation,@aws-sdk/client-organizations,@aws-sdk/client-s3,@aws-sdk/client-ssm,@aws-sdk/client-sso-admin,@aws-sdk/client-sts,@aws-sdk/credential-providers,clipanion,liquidjs,zip-lib" }, { "exec": "yarn install --check-files" }, { - "exec": "yarn upgrade @stylistic/eslint-plugin @types/jest @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser commit-and-tag-version constructs eslint-import-resolver-typescript eslint-plugin-import eslint jest jest-junit projen ts-jest ts-node typescript @aws-cdk/cdk-assets-lib @aws-sdk/client-cloudformation @aws-sdk/client-organizations @aws-sdk/client-s3 @aws-sdk/client-ssm @aws-sdk/client-sso-admin @aws-sdk/client-sts @aws-sdk/credential-providers clipanion liquidjs zip-lib" + "exec": "yarn upgrade @stylistic/eslint-plugin @types/jest @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser aws-sdk-client-mock commit-and-tag-version constructs eslint-import-resolver-typescript eslint-plugin-import eslint jest jest-junit projen ts-jest ts-node typescript @aws-cdk/cdk-assets-lib @aws-sdk/client-cloudformation @aws-sdk/client-organizations @aws-sdk/client-s3 @aws-sdk/client-ssm @aws-sdk/client-sso-admin @aws-sdk/client-sts @aws-sdk/credential-providers clipanion liquidjs zip-lib" }, { "exec": "npx projen" diff --git a/.projenrc.ts b/.projenrc.ts index 4e91989..793c984 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -30,6 +30,9 @@ const project = new typescript.TypeScriptProject({ 'typescript', 'zip-lib', ], + devDeps: [ + 'aws-sdk-client-mock', + ], sampleCode: false, gitignore: ['/blueprints/**/package-lock.json', '/blueprints/**/yarn.lock'], githubOptions: { diff --git a/package.json b/package.json index d9d456c..11e2e99 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/node": "^22.18.1", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", + "aws-sdk-client-mock": "^4.1.0", "commit-and-tag-version": "^12", "constructs": "^10.0.0", "eslint": "^9", diff --git a/test/commands/deploy.test.ts b/test/commands/deploy.test.ts new file mode 100644 index 0000000..13631b4 --- /dev/null +++ b/test/commands/deploy.test.ts @@ -0,0 +1,161 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { DescribeOrganizationCommand, ListRootsCommand, OrganizationsClient } from '@aws-sdk/client-organizations'; +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; +import { ListInstancesCommand, SSOAdminClient } from '@aws-sdk/client-sso-admin'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; +import { mockClient } from 'aws-sdk-client-mock'; +import { Cli } from 'clipanion'; +import { Deploy } from '../../src/commands/deploy'; +import { Init } from '../../src/commands/init'; +import { + AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, + awsAcceleratorConfigBucketName, loadConfigSync, +} from '../../src/config'; +import * as assets from '../../src/core/customizations/assets'; +import { executeCommand } from '../../src/core/util/exec'; + +// Mock the assets module +jest.mock('../../src/core/customizations/assets', () => ({ + customizationsPublishCdkAssets: jest.fn(), +})); + +describe('Deploy command', () => { + // Create mocks for AWS services + const ssmMock = mockClient(SSMClient); + const stsMock = mockClient(STSClient); + const organizationsMock = mockClient(OrganizationsClient); + const ssoAdminMock = mockClient(SSOAdminClient); + const s3Mock = mockClient(S3Client); + + let testProjectDirectory = ''; + + // Create spies for the functions + let assetsSpy: jest.SpyInstance; + + beforeAll(() => { + // Create a temporary directory for the test project + testProjectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aws-luminarlz-cli-test-')); + + // Change working directory for test + process.chdir(testProjectDirectory); + }); + + beforeEach(() => { + // Clear mocks before each test + ssmMock.reset(); + stsMock.reset(); + organizationsMock.reset(); + ssoAdminMock.reset(); + s3Mock.reset(); + + // Reset all mocks + jest.clearAllMocks(); + + // Set up spies for the functions + assetsSpy = jest.spyOn(assets, 'customizationsPublishCdkAssets').mockResolvedValue(); + + // Mock SSM parameter for AWS Accelerator version + ssmMock.on(GetParameterCommand).resolves({ + Parameter: { + Name: AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, + Value: '1.12.2', + Type: 'String', + }, + }); + + // Mock STS GetCallerIdentity + stsMock.on(GetCallerIdentityCommand).resolves({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:role/Admin', + UserId: 'AROAEXAMPLE123', + }); + + // Mock Organizations DescribeOrganization + organizationsMock.on(DescribeOrganizationCommand).resolves({ + Organization: { + Id: 'o-exampleorg', + Arn: 'arn:aws:organizations::123456789012:organization/o-exampleorg', + MasterAccountId: '123456789012', + }, + }); + + // Mock Organizations ListRoots + organizationsMock.on(ListRootsCommand).resolves({ + Roots: [ + { + Id: 'r-exampleroot', + Arn: 'arn:aws:organizations::123456789012:root/o-exampleorg/r-exampleroot', + Name: 'Root', + }, + ], + }); + + // Mock SSO Admin ListInstances + ssoAdminMock.on(ListInstancesCommand).resolves({ + Instances: [ + { + InstanceArn: 'arn:aws:sso:::instance/ssoins-example', + IdentityStoreId: 'd-example123', + }, + ], + }); + }); + + it('should deploy after initializing a project with the specified blueprint', async () => { + // Run the init command to set up the project + const initCli = new Cli(); + initCli.register(Init); + const initExitCode = await initCli.run([ + 'init', + '--blueprint', 'foundational', + '--accounts-root-email', 'test@example.com', + '--region', 'us-east-1', + '--force', + ]); + + // Install dependencies after initialization + await executeCommand('npm install', { cwd: testProjectDirectory }); + + // Verify init was successful + expect(initExitCode).toBe(0); + + // Now create CLI instance with Deploy command + const deployCli = new Cli(); + deployCli.register(Deploy); + + // Run the deploy command + const deployExitCode = await deployCli.run(['deploy']); + + // Verify deploy was successful + expect(deployExitCode).toBe(0); + + // Verify that the accelerator config output directory was created and contains files + const config = loadConfigSync(); + const outPath = path.join(testProjectDirectory, config.awsAcceleratorConfigOutPath); + expect(fs.existsSync(outPath)).toBe(true); + const outFiles = fs.readdirSync(outPath, { recursive: false }); + expect(outFiles.length).toBeGreaterThan(0); + + // Verify that cdk.out templates were copied into the output directory + const cdkOutPath = path.join(outPath, config.cdkOutPath); + expect(fs.existsSync(cdkOutPath)).toBe(true); + const cdkFiles = fs + .readdirSync(cdkOutPath, { recursive: true }) + .filter((f) => f.toString().endsWith('.template.json')); + expect(cdkFiles.length).toBeGreaterThan(0); + + // Verify that accelerator config was uploaded to S3 + const s3Calls = s3Mock.commandCalls(PutObjectCommand); + const callInput = s3Calls[0].args[0].input; + expect(callInput.Bucket).toBe(awsAcceleratorConfigBucketName(config)); + expect(callInput.Key).toBe(config.awsAcceleratorConfigDeploymentArtifactPath); + expect(Buffer.isBuffer(callInput.Body)).toBe(true); + + + // Verify that the customizationsPublishCdkAssets function was called + expect(assetsSpy).toHaveBeenCalled(); + }, 120 * 1000); +}); diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts new file mode 100644 index 0000000..c1e17c2 --- /dev/null +++ b/test/commands/init.test.ts @@ -0,0 +1,125 @@ +import * as fs from 'fs'; +import os from 'node:os'; +import * as path from 'path'; +import { OrganizationsClient, DescribeOrganizationCommand, ListRootsCommand } from '@aws-sdk/client-organizations'; +import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'; +import { SSOAdminClient, ListInstancesCommand } from '@aws-sdk/client-sso-admin'; +import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; +import { mockClient } from 'aws-sdk-client-mock'; +import { Cli } from 'clipanion'; +import { Init } from '../../src/commands/init'; +import { + AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, +} from '../../src/config'; + +describe('Init Command', () => { + // Mock AWS clients + const stsMock = mockClient(STSClient); + const orgMock = mockClient(OrganizationsClient); + const ssoMock = mockClient(SSOAdminClient); + const ssmMock = mockClient(SSMClient); + + const originalCwd = process.cwd(); + let testProjectDirectory = ''; + + beforeAll(() => { + // Create a temporary directory for the test project + testProjectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aws-luminarlz-cli-test-')); + + // Change working directory for test + process.chdir(testProjectDirectory); + }); + + afterAll(() => { + // Restore original working directory + process.chdir(originalCwd); + }); + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + stsMock.reset(); + orgMock.reset(); + ssoMock.reset(); + ssmMock.reset(); + + // Setup AWS mock responses + stsMock.on(GetCallerIdentityCommand).resolves({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:user/test-user', + UserId: 'AIDATEST123456', + }); + + orgMock.on(DescribeOrganizationCommand).resolves({ + Organization: { + Id: 'o-abcdef1234', + Arn: 'arn:aws:organizations::123456789012:organization/o-abcdef1234', + FeatureSet: 'ALL', + MasterAccountArn: 'arn:aws:organizations::123456789012:account/o-abcdef1234/123456789012', + MasterAccountEmail: 'master@example.com', + MasterAccountId: '123456789012', + }, + }); + + orgMock.on(ListRootsCommand).resolves({ + Roots: [ + { + Id: 'r-abcd1234', + Arn: 'arn:aws:organizations::123456789012:root/o-abcdef1234/r-abcd1234', + Name: 'Root', + PolicyTypes: [], + }, + ], + }); + + ssoMock.on(ListInstancesCommand).resolves({ + Instances: [ + { + InstanceArn: 'arn:aws:sso:::instance/ssoins-12345678901234567', + IdentityStoreId: 'd-12345678ab', + }, + ], + }); + + ssmMock.on(GetParameterCommand).resolves({ + Parameter: { + Name: AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, + Value: '1.12.2', + Type: 'String', + }, + }); + }); + + it('should initialize a project with the specified blueprint and create a config.ts with expected content', async () => { + // Create CLI instance with Init command + const cli = new Cli(); + cli.register(Init); + + // Define test params + const region = 'us-east-1'; + const email = 'test@example.com'; + + // Run the command + const exitCode = await cli.run([ + 'init', + '--region', region, + '--accounts-root-email', email, + ]); + + // Verify successful execution + expect(exitCode).toBe(0); + + const configPath = path.join(testProjectDirectory, 'config.ts'); + expect(fs.existsSync(configPath)).toBe(true); + + const configContent = fs.readFileSync(configPath, 'utf8'); + + // Assert constants rendered into config.ts + expect(configContent).toContain("export const AWS_ACCELERATOR_VERSION = '1.12.2'"); + expect(configContent).toContain("export const MANAGEMENT_ACCOUNT_ID = '123456789012'"); + expect(configContent).toContain("export const ORGANIZATION_ID = 'o-abcdef1234'"); + expect(configContent).toContain("export const ROOT_OU_ID = 'r-abcd1234'"); + expect(configContent).toContain("export const AWS_ACCOUNTS_ROOT_EMAIL = 'test@example.com'"); + expect(configContent).toContain("export const HOME_REGION = 'us-east-1'"); + }); +}); \ No newline at end of file diff --git a/test/commands/lza-config-validate.test.ts b/test/commands/lza-config-validate.test.ts new file mode 100644 index 0000000..6ed99c8 --- /dev/null +++ b/test/commands/lza-config-validate.test.ts @@ -0,0 +1,167 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { DescribeOrganizationCommand, ListRootsCommand, OrganizationsClient } from '@aws-sdk/client-organizations'; +import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; +import { ListInstancesCommand, SSOAdminClient } from '@aws-sdk/client-sso-admin'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; +import { mockClient } from 'aws-sdk-client-mock'; +import { Cli } from 'clipanion'; +import { Init } from '../../src/commands/init'; +import { LzaConfigValidate } from '../../src/commands/lza-config-validate'; +import { AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, LZA_SOURCE_PATH, loadConfigSync } from '../../src/config'; +import { getCheckoutPath } from '../../src/core/accelerator/repository/checkout'; +import * as execModule from '../../src/core/util/exec'; + +describe('LZA Config Validate command', () => { + // Create mocks for AWS services (used during init rendering) + const ssmMock = mockClient(SSMClient); + const stsMock = mockClient(STSClient); + const organizationsMock = mockClient(OrganizationsClient); + const ssoAdminMock = mockClient(SSOAdminClient); + + let testProjectDirectory = ''; + let execSpy: jest.SpyInstance; + const realExecute = execModule.executeCommand; + + beforeAll(() => { + // Create a temporary directory for the test project + testProjectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aws-luminarlz-cli-test-')); + + // Change working directory for test + process.chdir(testProjectDirectory); + }); + + beforeEach(() => { + // Clear and reset mocks before each test + ssmMock.reset(); + stsMock.reset(); + organizationsMock.reset(); + ssoAdminMock.reset(); + jest.clearAllMocks(); + + // Set up executeCommand spy with passthrough. Intercept only cloning and building of the LZA repo + execSpy = jest.spyOn(execModule, 'executeCommand').mockImplementation(((command: any, opts: any) => { + if (typeof command === 'string') { + if (command.startsWith('git clone ')) { + return Promise.resolve({ stdout: '', stderr: '' } as any) as any; + } + if (command.startsWith('yarn')) { + return Promise.resolve({ stdout: '', stderr: '' } as any) as any; + } + } + return (realExecute as any)(command, opts); + }) as any); + + // Mock SSM parameter for AWS Accelerator version + ssmMock.on(GetParameterCommand).resolves({ + Parameter: { + Name: AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, + Value: '1.12.2', + Type: 'String', + }, + }); + + // Mock STS GetCallerIdentity + stsMock.on(GetCallerIdentityCommand).resolves({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:role/Admin', + UserId: 'AROAEXAMPLE123', + }); + + // Mock Organizations DescribeOrganization + organizationsMock.on(DescribeOrganizationCommand).resolves({ + Organization: { + Id: 'o-exampleorg', + Arn: 'arn:aws:organizations::123456789012:organization/o-exampleorg', + MasterAccountId: '123456789012', + }, + }); + + // Mock Organizations ListRoots + organizationsMock.on(ListRootsCommand).resolves({ + Roots: [ + { + Id: 'r-exampleroot', + Arn: 'arn:aws:organizations::123456789012:root/o-exampleorg/r-exampleroot', + Name: 'Root', + }, + ], + }); + + // Mock SSO Admin ListInstances + ssoAdminMock.on(ListInstancesCommand).resolves({ + Instances: [ + { + InstanceArn: 'arn:aws:sso:::instance/ssoins-example', + IdentityStoreId: 'd-example123', + }, + ], + }); + }); + + it('should synthesize and validate after initializing a project with the specified blueprint', async () => { + // Run the init command to set up the project + const initCli = new Cli(); + initCli.register(Init); + const initExitCode = await initCli.run([ + 'init', + '--blueprint', 'foundational', + '--accounts-root-email', 'test@example.com', + '--region', 'us-east-1', + '--force', + ]); + + // Install dependencies after initialization + await execModule.executeCommand('npm install', { cwd: testProjectDirectory }); + + // Verify init was successful + expect(initExitCode).toBe(0); + + // Now create CLI instance with LzaConfigValidate command + const validateCli = new Cli(); + validateCli.register(LzaConfigValidate); + + // Run the lza config validate command + const validateExitCode = await validateCli.run(['lza', 'config', 'validate']); + + // Verify command was successful + expect(validateExitCode).toBe(0); + + // Verify that the accelerator config output directory was created and contains files + const config = loadConfigSync(); + const outPath = path.join(testProjectDirectory, config.awsAcceleratorConfigOutPath); + expect(fs.existsSync(outPath)).toBe(true); + const outFiles = fs.readdirSync(outPath, { recursive: false }); + expect(outFiles.length).toBeGreaterThan(0); + + // Verify that cdk.out templates were copied into the output directory + const cdkOutPath = path.join(outPath, config.cdkOutPath); + expect(fs.existsSync(cdkOutPath)).toBe(true); + const cdkFiles = fs + .readdirSync(cdkOutPath, { recursive: true }) + .filter((f) => f.toString().endsWith('.template.json')); + expect(cdkFiles.length).toBeGreaterThan(0); + + // Ensure executeCommand was called to run validate-config with correct parameters + const expectedConfigDir = path.join(testProjectDirectory, config.awsAcceleratorConfigOutPath); + const expectedCwd = path.join(getCheckoutPath(), LZA_SOURCE_PATH); + const validateCalls = execSpy.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.startsWith('yarn validate-config')); + expect(validateCalls.length).toBe(1); + expect(validateCalls[0][0]).toBe(`yarn validate-config ${expectedConfigDir}`); + expect(validateCalls[0][1]?.cwd).toBe(expectedCwd); + + // Ensure executeCommand was called to clone the repository and then build it + const cloneCalls = execSpy.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.startsWith('git clone ')); + expect(cloneCalls.length).toBe(1); + + const buildCalls = execSpy.mock.calls.filter(([cmd, opts]) => typeof cmd === 'string' && cmd.includes('yarn') && cmd.includes('build') && opts?.cwd === expectedCwd); + expect(buildCalls.length).toBe(1); + + // Verify that yarn && yarn build was called after git clone + const cloneIndex = execSpy.mock.calls.findIndex(([cmd]) => typeof cmd === 'string' && cmd.startsWith('git clone ')); + const buildIndex = execSpy.mock.calls.findIndex(([cmd, opts]) => typeof cmd === 'string' && cmd.includes('yarn') && cmd.includes('build') && opts?.cwd === expectedCwd); + expect(cloneIndex).toBeGreaterThanOrEqual(0); + expect(buildIndex).toBeGreaterThan(cloneIndex); + }, 120 * 1000); +}); \ No newline at end of file diff --git a/test/commands/lza-core-bootstrap.test.ts b/test/commands/lza-core-bootstrap.test.ts new file mode 100644 index 0000000..6ac43a7 --- /dev/null +++ b/test/commands/lza-core-bootstrap.test.ts @@ -0,0 +1,173 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { DescribeOrganizationCommand, ListRootsCommand, OrganizationsClient } from '@aws-sdk/client-organizations'; +import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; +import { ListInstancesCommand, SSOAdminClient } from '@aws-sdk/client-sso-admin'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; +import { mockClient } from 'aws-sdk-client-mock'; +import { Cli } from 'clipanion'; +import { Init } from '../../src/commands/init'; +import { LzaCoreBootstrap } from '../../src/commands/lza-core-bootstrap'; +import { AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, LZA_ACCELERATOR_PACKAGE_PATH, LZA_SOURCE_PATH, loadConfigSync } from '../../src/config'; +import { getCheckoutPath } from '../../src/core/accelerator/repository/checkout'; +import * as execModule from '../../src/core/util/exec'; + +describe('LZA Core Bootstrap command', () => { + // Create mocks for AWS services (used during init rendering) + const ssmMock = mockClient(SSMClient); + const stsMock = mockClient(STSClient); + const organizationsMock = mockClient(OrganizationsClient); + const ssoAdminMock = mockClient(SSOAdminClient); + + let testProjectDirectory = ''; + let execSpy: jest.SpyInstance; + const realExecute = execModule.executeCommand; + + beforeAll(() => { + // Create a temporary directory for the test project + testProjectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aws-luminarlz-cli-bootstrap-test-')); + + // Change working directory for test + process.chdir(testProjectDirectory); + }); + + beforeEach(() => { + // Clear and reset mocks before each test + ssmMock.reset(); + stsMock.reset(); + organizationsMock.reset(); + ssoAdminMock.reset(); + jest.clearAllMocks(); + + // Set up executeCommand spy with passthrough. Intercept only: + // - git clone ... (avoid network) + // - yarn && yarn build in LZA checkout (avoid building repo) + // - yarn run ts-node --transpile-only cdk.ts synth ... (avoid running LZA synth) + // - yarn run ts-node --transpile-only cdk.ts bootstrap ... (avoid running bootstrap) + // Do NOT intercept `npx cdk synth` from customizationsCdkSynth + execSpy = jest.spyOn(execModule, 'executeCommand').mockImplementation(((command: any, opts: any) => { + if (typeof command === 'string') { + if (command.startsWith('git clone ')) { + return Promise.resolve({ stdout: '', stderr: '' } as any) as any; + } + // yarn && yarn build interception (in checkout source path) + if (command.startsWith('yarn')) { + return Promise.resolve({ stdout: '', stderr: '' } as any) as any; + } + } + return (realExecute as any)(command, opts); + }) as any); + + // Mock SSM parameter for AWS Accelerator version + ssmMock.on(GetParameterCommand).resolves({ + Parameter: { + Name: AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, + Value: '1.12.2', + Type: 'String', + }, + }); + + // Mock STS GetCallerIdentity + stsMock.on(GetCallerIdentityCommand).resolves({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:role/Admin', + UserId: 'AROAEXAMPLE123', + }); + + // Mock Organizations DescribeOrganization + organizationsMock.on(DescribeOrganizationCommand).resolves({ + Organization: { + Id: 'o-exampleorg', + Arn: 'arn:aws:organizations::123456789012:organization/o-exampleorg', + MasterAccountId: '123456789012', + }, + }); + + // Mock Organizations ListRoots + organizationsMock.on(ListRootsCommand).resolves({ + Roots: [ + { + Id: 'r-exampleroot', + Arn: 'arn:aws:organizations::123456789012:root/o-exampleorg/r-exampleroot', + Name: 'Root', + }, + ], + }); + + // Mock SSO Admin ListInstances + ssoAdminMock.on(ListInstancesCommand).resolves({ + Instances: [ + { + InstanceArn: 'arn:aws:sso:::instance/ssoins-example', + IdentityStoreId: 'd-example123', + }, + ], + }); + }); + + it('should synthesize, run accelerator synth, and bootstrap after initializing a project', async () => { + // Run the init command to set up the project + const initCli = new Cli(); + initCli.register(Init); + const initExitCode = await initCli.run([ + 'init', + '--blueprint', 'foundational', + '--accounts-root-email', 'test@example.com', + '--region', 'us-east-1', + '--force', + ]); + + // Install dependencies after initialization + await execModule.executeCommand('npm install', { cwd: testProjectDirectory }); + + // Verify init was successful + expect(initExitCode).toBe(0); + + // Now create CLI instance with LzaCoreBootstrap command + const cli = new Cli(); + cli.register(LzaCoreBootstrap); + + // Run the lza core bootstrap command + const exitCode = await cli.run(['lza', 'core', 'bootstrap']); + + // Verify command was successful + expect(exitCode).toBe(0); + + // Verify that the accelerator config output directory was created and contains files + const config = loadConfigSync(); + const outPath = path.join(testProjectDirectory, config.awsAcceleratorConfigOutPath); + expect(fs.existsSync(outPath)).toBe(true); + const outFiles = fs.readdirSync(outPath, { recursive: false }); + expect(outFiles.length).toBeGreaterThan(0); + + // Ensure executeCommand was called to clone the repository and then build it + const expectedCheckoutSourceCwd = path.join(getCheckoutPath(), LZA_SOURCE_PATH); + const cloneCalls = execSpy.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.startsWith('git clone ')); + expect(cloneCalls.length).toBeGreaterThan(0); + + const buildCalls = execSpy.mock.calls.filter(([cmd, opts]) => typeof cmd === 'string' && cmd.includes('yarn') && cmd.includes('build') && opts?.cwd === expectedCheckoutSourceCwd); + expect(buildCalls.length).toBeGreaterThan(0); + + // Verify order: clone -> build + const cloneIndex = execSpy.mock.calls.findIndex(([cmd]) => typeof cmd === 'string' && cmd.startsWith('git clone ')); + const buildIndex = execSpy.mock.calls.findIndex(([cmd, opts]) => typeof cmd === 'string' && cmd.includes('yarn') && cmd.includes('build') && opts?.cwd === expectedCheckoutSourceCwd); + expect(cloneIndex).toBeGreaterThanOrEqual(0); + expect(buildIndex).toBeGreaterThan(cloneIndex); + + // Ensure executeCommand was called to run accelerator cdk synth with correct parameters and cwd + const expectedAccelCwd = path.join(getCheckoutPath(), LZA_ACCELERATOR_PACKAGE_PATH); + const synthCalls = execSpy.mock.calls.filter(([cmd, opts]) => typeof cmd === 'string' && cmd.startsWith('yarn run ts-node') && cmd.includes('cdk.ts synth') && opts?.cwd === expectedAccelCwd); + expect(synthCalls.length).toBe(1); + + // Ensure executeCommand was called to run accelerator cdk bootstrap with correct cwd + const bootstrapCalls = execSpy.mock.calls.filter(([cmd, opts]) => typeof cmd === 'string' && cmd.startsWith('yarn run ts-node') && cmd.includes('cdk.ts bootstrap') && opts?.cwd === expectedAccelCwd); + expect(bootstrapCalls.length).toBe(1); + + // Verify order: synth -> bootstrap + const synthIndex = execSpy.mock.calls.findIndex(([cmd, opts]) => typeof cmd === 'string' && cmd.startsWith('yarn run ts-node') && cmd.includes('cdk.ts synth') && opts?.cwd === expectedAccelCwd); + const bootstrapIndex = execSpy.mock.calls.findIndex(([cmd, opts]) => typeof cmd === 'string' && cmd.startsWith('yarn run ts-node') && cmd.includes('cdk.ts bootstrap') && opts?.cwd === expectedAccelCwd); + expect(synthIndex).toBeGreaterThan(buildIndex); + expect(bootstrapIndex).toBeGreaterThan(synthIndex); + }, 180 * 1000); +}); diff --git a/test/commands/synth.test.ts b/test/commands/synth.test.ts new file mode 100644 index 0000000..7896d46 --- /dev/null +++ b/test/commands/synth.test.ts @@ -0,0 +1,130 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { DescribeOrganizationCommand, ListRootsCommand, OrganizationsClient } from '@aws-sdk/client-organizations'; +import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; +import { ListInstancesCommand, SSOAdminClient } from '@aws-sdk/client-sso-admin'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; +import { mockClient } from 'aws-sdk-client-mock'; +import { Cli } from 'clipanion'; +import { Init } from '../../src/commands/init'; +import { Synth } from '../../src/commands/synth'; +import { AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, loadConfigSync } from '../../src/config'; +import { executeCommand } from '../../src/core/util/exec'; + +describe('Synth command', () => { + // Create mocks for AWS services (used during init rendering) + const ssmMock = mockClient(SSMClient); + const stsMock = mockClient(STSClient); + const organizationsMock = mockClient(OrganizationsClient); + const ssoAdminMock = mockClient(SSOAdminClient); + + let testProjectDirectory = ''; + + beforeAll(() => { + // Create a temporary directory for the test project + testProjectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aws-luminarlz-cli-test-')); + + // Change working directory for test + process.chdir(testProjectDirectory); + }); + + beforeEach(() => { + // Clear and reset mocks before each test + ssmMock.reset(); + stsMock.reset(); + organizationsMock.reset(); + ssoAdminMock.reset(); + jest.clearAllMocks(); + + // Mock SSM parameter for AWS Accelerator version + ssmMock.on(GetParameterCommand).resolves({ + Parameter: { + Name: AWS_ACCELERATOR_INSTALLER_STACK_VERSION_SSM_PARAMETER_NAME, + Value: '1.12.2', + Type: 'String', + }, + }); + + // Mock STS GetCallerIdentity + stsMock.on(GetCallerIdentityCommand).resolves({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:role/Admin', + UserId: 'AROAEXAMPLE123', + }); + + // Mock Organizations DescribeOrganization + organizationsMock.on(DescribeOrganizationCommand).resolves({ + Organization: { + Id: 'o-exampleorg', + Arn: 'arn:aws:organizations::123456789012:organization/o-exampleorg', + MasterAccountId: '123456789012', + }, + }); + + // Mock Organizations ListRoots + organizationsMock.on(ListRootsCommand).resolves({ + Roots: [ + { + Id: 'r-exampleroot', + Arn: 'arn:aws:organizations::123456789012:root/o-exampleorg/r-exampleroot', + Name: 'Root', + }, + ], + }); + + // Mock SSO Admin ListInstances + ssoAdminMock.on(ListInstancesCommand).resolves({ + Instances: [ + { + InstanceArn: 'arn:aws:sso:::instance/ssoins-example', + IdentityStoreId: 'd-example123', + }, + ], + }); + }); + + it('should synthesize after initializing a project with the specified blueprint', async () => { + // Run the init command to set up the project + const initCli = new Cli(); + initCli.register(Init); + const initExitCode = await initCli.run([ + 'init', + '--blueprint', 'foundational', + '--accounts-root-email', 'test@example.com', + '--region', 'us-east-1', + '--force', + ]); + + // Install dependencies after initialization + await executeCommand('npm install', { cwd: testProjectDirectory }); + + // Verify init was successful + expect(initExitCode).toBe(0); + + // Now create CLI instance with Synth command + const synthCli = new Cli(); + synthCli.register(Synth); + + // Run the synth command + const synthExitCode = await synthCli.run(['synth']); + + // Verify synth was successful + expect(synthExitCode).toBe(0); + + // Verify that the accelerator config output directory was created and contains files + const config = loadConfigSync(); + const outPath = path.join(testProjectDirectory, config.awsAcceleratorConfigOutPath); + expect(fs.existsSync(outPath)).toBe(true); + const outFiles = fs.readdirSync(outPath, { recursive: false }); + expect(outFiles.length).toBeGreaterThan(0); + + // Verify that cdk.out templates were copied into the output directory + const cdkOutPath = path.join(outPath, config.cdkOutPath); + expect(fs.existsSync(cdkOutPath)).toBe(true); + const cdkFiles = fs + .readdirSync(cdkOutPath, { recursive: true }) + .filter((f) => f.toString().endsWith('.template.json')); + expect(cdkFiles.length).toBeGreaterThan(0); + }, 120 * 1000); +}); diff --git a/yarn.lock b/yarn.lock index 38a81cd..7df17c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1815,13 +1815,20 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@sinonjs/commons@^3.0.0": +"@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== dependencies: type-detect "4.0.8" +"@sinonjs/fake-timers@11.2.2": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" + integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers@^10.0.2": version "10.3.0" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" @@ -1829,6 +1836,26 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@sinonjs/fake-timers@^13.0.1": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" + integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@sinonjs/samsam@^8.0.0": + version "8.0.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.3.tgz#eb6ffaef421e1e27783cc9b52567de20cb28072d" + integrity sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ== + dependencies: + "@sinonjs/commons" "^3.0.1" + type-detect "^4.1.0" + +"@sinonjs/text-encoding@^0.7.3": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz#282046f03e886e352b2d5f5da5eb755e01457f3f" + integrity sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA== + "@smithy/abort-controller@^4.0.5", "@smithy/abort-controller@^4.1.1": version "4.1.1" resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.1.1.tgz#9b3872ab6b2c061486175c281dadc0a853260533" @@ -2468,6 +2495,18 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/sinon@^17.0.3": + version "17.0.4" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.4.tgz#fd9a3e8e07eea1a3f4a6f82a972c899e5778f369" + integrity sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.5" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== + "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -2934,6 +2973,15 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +aws-sdk-client-mock@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz#ae1950b2277f8e65f9a039975d79ff9fffab39e3" + integrity sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw== + dependencies: + "@types/sinon" "^17.0.3" + sinon "^18.0.1" + tslib "^2.1.0" + b4a@^1.6.4: version "1.7.1" resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.7.1.tgz#6fd4ec2fb33ba7a4ff341a2869bbfc88a6e57850" @@ -3677,6 +3725,11 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -5458,6 +5511,11 @@ just-diff@^6.0.0: resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-6.0.2.tgz#03b65908543ac0521caf6d8eb85035f7d27ea285" integrity sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA== +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -5776,6 +5834,17 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nise@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/nise/-/nise-6.1.1.tgz#78ea93cc49be122e44cb7c8fdf597b0e8778b64a" + integrity sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^13.0.1" + "@sinonjs/text-encoding" "^0.7.3" + just-extend "^6.2.0" + path-to-regexp "^8.1.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -6072,6 +6141,11 @@ path-scurry@^2.0.0: lru-cache "^11.0.0" minipass "^7.1.2" +path-to-regexp@^8.1.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" + integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== + path-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -6585,6 +6659,18 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +sinon@^18.0.1: + version "18.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-18.0.1.tgz#464334cdfea2cddc5eda9a4ea7e2e3f0c7a91c5e" + integrity sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.2.0" + nise "^6.0.0" + supports-color "^7" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -6837,7 +6923,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0: +supports-color@^7, supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -7004,7 +7090,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.4.0, tslib@^2.6.2: +tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -7026,6 +7112,11 @@ type-detect@4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + type-fest@^0.18.0: version "0.18.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f"