diff --git a/packages/parameters/tests/e2e/appConfigProvider.class.test.functionCode.ts b/packages/parameters/tests/e2e/appConfigProvider.class.test.functionCode.ts new file mode 100644 index 0000000000..589fd475ed --- /dev/null +++ b/packages/parameters/tests/e2e/appConfigProvider.class.test.functionCode.ts @@ -0,0 +1,118 @@ +import { Context } from 'aws-lambda'; +import { + AppConfigProvider, +} from '../../src/appconfig'; +import { + AppConfigGetOptionsInterface, +} from '../../src/types'; +import { TinyLogger } from '../helpers/tinyLogger'; +import { middleware } from '../helpers/sdkMiddlewareRequestCounter'; +import { AppConfigDataClient } from '@aws-sdk/client-appconfigdata'; + +// We use a custom logger to log pure JSON objects to stdout +const logger = new TinyLogger(); + +const application = process.env.APPLICATION_NAME || 'my-app'; +const environment = process.env.ENVIRONMENT_NAME || 'my-env'; +const freeFormJsonName = process.env.FREEFORM_JSON_NAME || 'freeform-json'; +const freeFormYamlName = process.env.FREEFORM_YAML_NAME || 'freeform-yaml'; +const freeFormPlainTextNameA = process.env.FREEFORM_PLAIN_TEXT_NAME_A || 'freeform-plain-text'; +const freeFormPlainTextNameB = process.env.FREEFORM_PLAIN_TEXT_NAME_B || 'freeform-plain-text'; +const featureFlagName = process.env.FEATURE_FLAG_NAME || 'feature-flag'; + +const defaultProvider = new AppConfigProvider({ + application, + environment, +}); +// Provider test +const customClient = new AppConfigDataClient({}); +customClient.middlewareStack.use(middleware); +const providerWithMiddleware = new AppConfigProvider({ + awsSdkV3Client: customClient, + application, + environment, +}); + +// Use provider specified, or default to main one & return it with cache cleared +const resolveProvider = (provider?: AppConfigProvider): AppConfigProvider => { + const resolvedProvider = provider ? provider : defaultProvider; + resolvedProvider.clearCache(); + + return resolvedProvider; +}; + +// Helper function to call get() and log the result +const _call_get = async ( + paramName: string, + testName: string, + options?: AppConfigGetOptionsInterface, + provider?: AppConfigProvider, +): Promise => { + try { + const currentProvider = resolveProvider(provider); + + const parameterValue = await currentProvider.get(paramName, options); + logger.log({ + test: testName, + value: parameterValue + }); + } catch (err) { + logger.log({ + test: testName, + error: err.message + }); + } +}; + +export const handler = async (_event: unknown, _context: Context): Promise => { + // Test 1 - get a single parameter as-is (no transformation) + await _call_get(freeFormPlainTextNameA, 'get'); + + // Test 2 - get a free-form JSON and apply binary transformation (should return a stringified JSON) + await _call_get(freeFormJsonName, 'get-freeform-json-binary', { transform: 'binary' }); + + // Test 3 - get a free-form YAML and apply binary transformation (should return a string-encoded YAML) + await _call_get(freeFormYamlName, 'get-freeform-yaml-binary', { transform: 'binary' }); + + // Test 4 - get a free-form plain text and apply binary transformation (should return a string) + await _call_get(freeFormPlainTextNameB, 'get-freeform-plain-text-binary', { transform: 'binary' }); + + // Test 5 - get a feature flag and apply binary transformation (should return a stringified JSON) + await _call_get(featureFlagName, 'get-feature-flag-binary', { transform: 'binary' }); + + // Test 6 + // get parameter twice with middleware, which counts the number of requests, we check later if we only called AppConfig API once + try { + providerWithMiddleware.clearCache(); + middleware.counter = 0; + await providerWithMiddleware.get(freeFormPlainTextNameA); + await providerWithMiddleware.get(freeFormPlainTextNameA); + logger.log({ + test: 'get-cached', + value: middleware.counter // should be 1 + }); + } catch (err) { + logger.log({ + test: 'get-cached', + error: err.message + }); + } + + // Test 7 + // get parameter twice, but force fetch 2nd time, we count number of SDK requests and check that we made two API calls + try { + providerWithMiddleware.clearCache(); + middleware.counter = 0; + await providerWithMiddleware.get(freeFormPlainTextNameA); + await providerWithMiddleware.get(freeFormPlainTextNameA, { forceFetch: true }); + logger.log({ + test: 'get-forced', + value: middleware.counter // should be 2 + }); + } catch (err) { + logger.log({ + test: 'get-forced', + error: err.message + }); + } +}; \ No newline at end of file diff --git a/packages/parameters/tests/e2e/appConfigProvider.class.test.ts b/packages/parameters/tests/e2e/appConfigProvider.class.test.ts new file mode 100644 index 0000000000..b32504444a --- /dev/null +++ b/packages/parameters/tests/e2e/appConfigProvider.class.test.ts @@ -0,0 +1,360 @@ +/** + * Test AppConfigProvider class + * + * @group e2e/parameters/appconfig/class + */ +import path from 'path'; +import { App, Stack, Aspects } from 'aws-cdk-lib'; +import { v4 } from 'uuid'; +import { + generateUniqueName, + isValidRuntimeKey, + createStackWithLambdaFunction, + invokeFunction, +} from '../../../commons/tests/utils/e2eUtils'; +import { InvocationLogs } from '../../../commons/tests/utils/InvocationLogs'; +import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; +import { ResourceAccessGranter } from '../helpers/cdkAspectGrantAccess'; +import { + RESOURCE_NAME_PREFIX, + SETUP_TIMEOUT, + TEARDOWN_TIMEOUT, + TEST_CASE_TIMEOUT +} from './constants'; +import { + createBaseAppConfigResources, + createAppConfigConfigurationProfile, +} from '../helpers/parametersUtils'; + +const runtime: string = process.env.RUNTIME || 'nodejs18x'; + +if (!isValidRuntimeKey(runtime)) { + throw new Error(`Invalid runtime key value: ${runtime}`); +} + +const uuid = v4(); +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'appConfigProvider'); +const functionName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'appConfigProvider'); +const lambdaFunctionCodeFile = 'appConfigProvider.class.test.functionCode.ts'; + +const invocationCount = 1; + +const applicationName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'app'); +const environmentName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'env'); +const deploymentStrategyName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'immediate'); +const freeFormJsonName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'freeFormJson'); +const freeFormYamlName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'freeFormYaml'); +const freeFormPlainTextNameA = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'freeFormPlainTextA'); +const freeFormPlainTextNameB = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'freeFormPlainTextB'); +const featureFlagName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'featureFlag'); + +const freeFormJsonValue = { + foo: 'bar', +}; +const freeFormYamlValue = `foo: bar +`; +const freeFormPlainTextValue = 'foo'; +const featureFlagValue = { + version: '1', + flags: { + myFeatureFlag: { + 'name': 'myFeatureFlag', + } + }, + values: { + myFeatureFlag: { + enabled: true, + } + } +}; + +const integTestApp = new App(); +let stack: Stack; + +/** + * This test suite deploys a CDK stack with a Lambda function and a number of AppConfig parameters. + * The function code uses the Parameters utility to retrieve the parameters. + * It then logs the values to CloudWatch Logs as JSON objects. + * + * Once the stack is deployed, the Lambda function is invoked and the CloudWatch Logs are retrieved. + * The logs are then parsed and the values are checked against the expected values for each test case. + * + * The stack creates an AppConfig application and environment, and then creates a number configuration + * profiles, each with a different type of parameter. + * + * The parameters created are: + * - Free-form JSON + * - Free-form YAML + * - 2x Free-form plain text + * - Feature flag + * + * These parameters allow to retrieve the values and test some transformations. + * + * The tests are: + * + * Test 1 + * get a single parameter as-is (no transformation) + * + * Test 2 + * get a free-form JSON and apply binary transformation (should return a stringified JSON) + * + * Test 3 + * get a free-form YAML and apply binary transformation (should return a string-encoded YAML) + * + * Test 4 + * get a free-form plain text and apply binary transformation (should return a string) + * + * Test 5 + * get a feature flag and apply binary transformation (should return a stringified JSON) + * + * Test 6 + * get parameter twice with middleware, which counts the number of requests, + * we check later if we only called AppConfig API once + * + * Test 7 + * get parameter twice, but force fetch 2nd time, we count number of SDK requests and + * check that we made two API calls + * + * Note: To avoid race conditions, we add a dependency between each pair of configuration profiles. + * This allows us to influence the order of creation and ensure that each configuration profile + * is created after the previous one. This is necessary because we share the same AppConfig + * application and environment for all tests. + */ +describe(`parameters E2E tests (appConfigProvider) for runtime ${runtime}`, () => { + + let invocationLogs: InvocationLogs[]; + const encoder = new TextEncoder(); + + beforeAll(async () => { + // Create a stack with a Lambda function + stack = createStackWithLambdaFunction({ + app: integTestApp, + stackName, + functionName, + functionEntry: path.join(__dirname, lambdaFunctionCodeFile), + environment: { + UUID: uuid, + + // Values(s) to be used by Parameters in the Lambda function + APPLICATION_NAME: applicationName, + ENVIRONMENT_NAME: environmentName, + FREEFORM_JSON_NAME: freeFormJsonName, + FREEFORM_YAML_NAME: freeFormYamlName, + FREEFORM_PLAIN_TEXT_NAME_A: freeFormPlainTextNameA, + FREEFORM_PLAIN_TEXT_NAME_B: freeFormPlainTextNameB, + FEATURE_FLAG_NAME: featureFlagName, + }, + runtime, + }); + + // Create the base resources for an AppConfig application. + const { + application, + environment, + deploymentStrategy + } = createBaseAppConfigResources({ + stack, + applicationName, + environmentName, + deploymentStrategyName, + }); + + // Create configuration profiles for tests. + const freeFormJson = createAppConfigConfigurationProfile({ + stack, + application, + environment, + deploymentStrategy, + name: freeFormJsonName, + type: 'AWS.Freeform', + content: { + content: JSON.stringify(freeFormJsonValue), + contentType: 'application/json', + } + }); + + const freeFormYaml = createAppConfigConfigurationProfile({ + stack, + application, + environment, + deploymentStrategy, + name: freeFormYamlName, + type: 'AWS.Freeform', + content: { + content: freeFormYamlValue, + contentType: 'application/x-yaml', + } + }); + freeFormYaml.node.addDependency(freeFormJson); + + const freeFormPlainTextA = createAppConfigConfigurationProfile({ + stack, + application, + environment, + deploymentStrategy, + name: freeFormPlainTextNameA, + type: 'AWS.Freeform', + content: { + content: freeFormPlainTextValue, + contentType: 'text/plain', + } + }); + freeFormPlainTextA.node.addDependency(freeFormYaml); + + const freeFormPlainTextB = createAppConfigConfigurationProfile({ + stack, + application, + environment, + deploymentStrategy, + name: freeFormPlainTextNameB, + type: 'AWS.Freeform', + content: { + content: freeFormPlainTextValue, + contentType: 'text/plain', + } + }); + freeFormPlainTextB.node.addDependency(freeFormPlainTextA); + + const featureFlag = createAppConfigConfigurationProfile({ + stack, + application, + environment, + deploymentStrategy, + name: featureFlagName, + type: 'AWS.AppConfig.FeatureFlags', + content: { + content: JSON.stringify(featureFlagValue), + contentType: 'application/json', + } + }); + featureFlag.node.addDependency(freeFormPlainTextB); + + // Grant access to the Lambda function to the AppConfig resources. + Aspects.of(stack).add(new ResourceAccessGranter([ + freeFormJson, + freeFormYaml, + freeFormPlainTextA, + freeFormPlainTextB, + featureFlag, + ])); + + // Deploy the stack + await deployStack(integTestApp, stack); + + // and invoke the Lambda function + invocationLogs = await invokeFunction(functionName, invocationCount, 'SEQUENTIAL'); + + }, SETUP_TIMEOUT); + + describe('AppConfigProvider usage', () => { + + // Test 1 - get a single parameter as-is (no transformation) + it('should retrieve single parameter', () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[0]); + + expect(testLog).toStrictEqual({ + test: 'get', + value: JSON.parse( + JSON.stringify( + encoder.encode(freeFormPlainTextValue) + ) + ), + }); + + }); + + // Test 2 - get a free-form JSON and apply binary transformation + // (should return a stringified JSON) + it('should retrieve single free-form JSON parameter with binary transformation', () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[1]); + + expect(testLog).toStrictEqual({ + test: 'get-freeform-json-binary', + value: JSON.stringify(freeFormJsonValue), + }); + + }); + + // Test 3 - get a free-form YAML and apply binary transformation + // (should return a string-encoded YAML) + it('should retrieve single free-form YAML parameter with binary transformation', () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[2]); + + expect(testLog).toStrictEqual({ + test: 'get-freeform-yaml-binary', + value: freeFormYamlValue, + }); + + }); + + // Test 4 - get a free-form plain text and apply binary transformation + // (should return a string) + it('should retrieve single free-form plain text parameter with binary transformation', () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[3]); + + expect(testLog).toStrictEqual({ + test: 'get-freeform-plain-text-binary', + value: freeFormPlainTextValue, + }); + + }); + + // Test 5 - get a feature flag and apply binary transformation + // (should return a stringified JSON) + it('should retrieve single feature flag parameter with binary transformation', () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[4]); + + expect(testLog).toStrictEqual({ + test: 'get-feature-flag-binary', + value: JSON.stringify(featureFlagValue.values), + }); + + }); + + // Test 6 - get parameter twice with middleware, which counts the number + // of requests, we check later if we only called AppConfig API once + it('should retrieve single parameter cached', () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[5]); + + expect(testLog).toStrictEqual({ + test: 'get-cached', + value: 1 + }); + + }, TEST_CASE_TIMEOUT); + + // Test 7 - get parameter twice, but force fetch 2nd time, + // we count number of SDK requests and check that we made two API calls + it('should retrieve single parameter twice without caching', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[6]); + + expect(testLog).toStrictEqual({ + test: 'get-forced', + value: 2 + }); + + }, TEST_CASE_TIMEOUT); + + }); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await destroyStack(integTestApp, stack); + } + }, TEARDOWN_TIMEOUT); + +}); \ No newline at end of file diff --git a/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts b/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts index f75848acd5..68f755a3fc 100644 --- a/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts +++ b/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts @@ -1,9 +1,10 @@ -import { IAspect } from 'aws-cdk-lib'; +import { IAspect, Stack } from 'aws-cdk-lib'; import { IConstruct } from 'constructs'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { Table } from 'aws-cdk-lib/aws-dynamodb'; import { PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; +import { CfnDeployment } from 'aws-cdk-lib/aws-appconfig'; import { StringParameter, IStringParameter } from 'aws-cdk-lib/aws-ssm'; const isStringParameterGeneric = (parameter: IConstruct): parameter is StringParameter | IStringParameter => @@ -24,9 +25,9 @@ const isStringParameterGeneric = (parameter: IConstruct): parameter is StringPar * @see {@link https://docs.aws.amazon.com/cdk/v2/guide/aspects.html CDK Docs - Aspects} */ export class ResourceAccessGranter implements IAspect { - private readonly resources: Table[] | Secret[] | StringParameter[] | IStringParameter[]; + private readonly resources: Table[] | Secret[] | StringParameter[] | IStringParameter[] | CfnDeployment[]; - public constructor(resources: Table[] | Secret[] | StringParameter[] | IStringParameter[]) { + public constructor(resources: Table[] | Secret[] | StringParameter[] | IStringParameter[] | CfnDeployment[]) { this.resources = resources; } @@ -35,7 +36,7 @@ export class ResourceAccessGranter implements IAspect { if (node instanceof NodejsFunction) { // Grant access to the resources - this.resources.forEach((resource: Table | Secret | StringParameter | IStringParameter) => { + this.resources.forEach((resource: Table | Secret | StringParameter | IStringParameter | CfnDeployment) => { if (resource instanceof Table) { resource.grantReadData(node); @@ -45,6 +46,7 @@ export class ResourceAccessGranter implements IAspect { resource.grantRead(node); } else if (isStringParameterGeneric(resource)) { resource.grantRead(node); + // Grant access also to the path of the parameter node.addToRolePolicy( new PolicyStatement({ @@ -57,9 +59,27 @@ export class ResourceAccessGranter implements IAspect { ], }), ); + } else if (resource instanceof CfnDeployment) { + const appConfigConfigurationArn = Stack.of(node).formatArn({ + service: 'appconfig', + resource: `application/${resource.applicationId}/environment/${resource.environmentId}/configuration/${resource.configurationProfileId}`, + }); + + node.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'appconfig:StartConfigurationSession', + 'appconfig:GetLatestConfiguration', + ], + resources: [ + appConfigConfigurationArn, + ], + }), + ); } }); } } -} \ No newline at end of file +} diff --git a/packages/parameters/tests/helpers/parametersUtils.ts b/packages/parameters/tests/helpers/parametersUtils.ts index e503fddd2b..3bd785bbed 100644 --- a/packages/parameters/tests/helpers/parametersUtils.ts +++ b/packages/parameters/tests/helpers/parametersUtils.ts @@ -6,6 +6,14 @@ import { Runtime } from 'aws-cdk-lib/aws-lambda'; import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { StringParameter, IStringParameter } from 'aws-cdk-lib/aws-ssm'; import { Table, TableProps, BillingMode } from 'aws-cdk-lib/aws-dynamodb'; +import { + CfnApplication, + CfnConfigurationProfile, + CfnDeployment, + CfnDeploymentStrategy, + CfnEnvironment, + CfnHostedConfigurationVersion, +} from 'aws-cdk-lib/aws-appconfig'; export type CreateDynamoDBTableOptions = { stack: Stack @@ -23,6 +31,108 @@ const createDynamoDBTable = (options: CreateDynamoDBTableOptions): Table => { return new Table(stack, id, props); }; +export type AppConfigResourcesOptions = { + stack: Stack + applicationName: string + environmentName: string + deploymentStrategyName: string +}; + +type AppConfigResourcesOutput = { + application: CfnApplication + environment: CfnEnvironment + deploymentStrategy: CfnDeploymentStrategy +}; + +/** + * Utility function to create the base resources for an AppConfig application. + */ +const createBaseAppConfigResources = (options: AppConfigResourcesOptions): AppConfigResourcesOutput => { + const { + stack, + applicationName, + environmentName, + deploymentStrategyName, + } = options; + + // create a new app config application. + const application = new CfnApplication( + stack, + 'application', + { + name: applicationName, + } + ); + + const environment = new CfnEnvironment(stack, 'environment', { + name: environmentName, + applicationId: application.ref, + }); + + const deploymentStrategy = new CfnDeploymentStrategy(stack, 'deploymentStrategy', { + name: deploymentStrategyName, + deploymentDurationInMinutes: 0, + growthFactor: 100, + replicateTo: 'NONE', + finalBakeTimeInMinutes: 0, + }); + + return { + application, + environment, + deploymentStrategy, + }; +}; + +export type CreateAppConfigConfigurationProfileOptions = { + stack: Stack + name: string + application: CfnApplication + environment: CfnEnvironment + deploymentStrategy: CfnDeploymentStrategy + type: 'AWS.Freeform' | 'AWS.AppConfig.FeatureFlags' + content: { + contentType: 'application/json' | 'application/x-yaml' | 'text/plain' + content: string + } +}; + +/** + * Utility function to create an AppConfig configuration profile and deployment. + */ +const createAppConfigConfigurationProfile = (options: CreateAppConfigConfigurationProfileOptions): CfnDeployment => { + const { + stack, + name, + application, + environment, + deploymentStrategy, + type, + content, + } = options; + + const configProfile = new CfnConfigurationProfile(stack, `${name}-configProfile`, { + name, + applicationId: application.ref, + locationUri: 'hosted', + type, + }); + + const configVersion = new CfnHostedConfigurationVersion(stack, `${name}-configVersion`, { + applicationId: application.ref, + configurationProfileId: configProfile.ref, + ...content + }); + + return new CfnDeployment(stack, `${name}-deployment`, { + applicationId: application.ref, + configurationProfileId: configProfile.ref, + configurationVersion: configVersion.ref, + deploymentStrategyId: deploymentStrategy.ref, + environmentId: environment.ref, + }); +}; + export type CreateSecureStringProviderOptions = { stack: Stack parametersPrefix: string @@ -93,6 +203,8 @@ const createSSMSecureString = (options: CreateSSMSecureStringOptions): IStringPa export { createDynamoDBTable, + createBaseAppConfigResources, + createAppConfigConfigurationProfile, createSSMSecureString, createSecureStringProvider, -}; \ No newline at end of file +}; diff --git a/packages/parameters/tests/helpers/sdkMiddlewareRequestCounter.ts b/packages/parameters/tests/helpers/sdkMiddlewareRequestCounter.ts index f71a6173eb..0fbff71084 100644 --- a/packages/parameters/tests/helpers/sdkMiddlewareRequestCounter.ts +++ b/packages/parameters/tests/helpers/sdkMiddlewareRequestCounter.ts @@ -17,9 +17,16 @@ export const middleware = { applyToStack: (stack) => { // Middleware added to mark start and end of an complete API call. stack.add( - (next, _context) => async (args) => { - // Increment counter - middleware.counter++; + (next, context) => async (args) => { + // We only want to count API calls to retrieve data, + // not the StartConfigurationSessionCommand + if ( + context.clientName !== 'AppConfigDataClient' || + context.commandName !== 'StartConfigurationSessionCommand' + ) { + // Increment counter + middleware.counter++; + } // Call next middleware return await next(args);