diff --git a/integ/aws/edge/Makefile b/integ/aws/edge/Makefile index 11764664..6b0d1553 100644 --- a/integ/aws/edge/Makefile +++ b/integ/aws/edge/Makefile @@ -21,6 +21,10 @@ distribution-policies: ## Test Distribution Policies go test -v -timeout 45m ./... -run ^TestDistributionPolicies$ .PHONY: distribution-policies +distribution-function: ## Test Distribution Function association + go test -v -timeout 45m ./... -run ^TestDistributionFunction$ +.PHONY: distribution-function + service-with-http-namespace: ## Test CloudMap Service with HTTP Namespace go test -v -timeout 30m ./... -run ^TestServiceWithHttpNamespace$ .PHONY: service-with-http-namespace diff --git a/integ/aws/edge/README.md b/integ/aws/edge/README.md index b142347a..05596ae9 100644 --- a/integ/aws/edge/README.md +++ b/integ/aws/edge/README.md @@ -15,6 +15,8 @@ Test Targets: kvs-jwt-verify Test Edge function for KVS JWT verify multi-zone-acm-pub-cert Test Multi Zone ACM Public Certificate distribution-policies Test Distribution Policies + distribution-function Test Distribution Function association + service-with-http-namespace Test CloudMap Service with HTTP Namespace Other Targets: help Print out every target with a description diff --git a/integ/aws/edge/apps/distribution-function.ts b/integ/aws/edge/apps/distribution-function.ts new file mode 100644 index 00000000..a97c847a --- /dev/null +++ b/integ/aws/edge/apps/distribution-function.ts @@ -0,0 +1,75 @@ +// https://github.com/aws/aws-cdk/blob/7926560f0a150d8fd39d0775df5259621b8068ae/packages/@aws-cdk-testing/framework-integ/test/aws-cloudfront/test/integ.distribution-function.ts +import { cloudfrontDistribution } from "@cdktn/provider-aws"; +import { App, LocalBackend } from "cdktn"; +import { aws } from "../../../../src"; + +const environmentName = process.env.ENVIRONMENT_NAME ?? "test"; +const region = process.env.AWS_REGION ?? "us-east-1"; +const outdir = process.env.OUT_DIR ?? "cdktf.out"; +const stackName = process.env.STACK_NAME ?? "distribution-function"; + +// https://github.com/aws/aws-cdk/blob/17b12f2aa7a2b519a6e802bf79d3099f2fcd7851/packages/@aws-cdk-testing/framework-integ/test/aws-cloudfront/test/test-origin.ts +/** Used for testing common Origin functionality */ +class TestOrigin extends aws.edge.OriginBase { + constructor(domainName: string, props: aws.edge.OriginProps = {}) { + super(domainName, props); + } + protected renderCustomOriginConfig(): + | cloudfrontDistribution.CloudfrontDistributionOriginCustomOriginConfig + | undefined { + return { + httpPort: 80, + httpsPort: 443, + originProtocolPolicy: aws.edge.OriginProtocolPolicy.HTTPS_ONLY, + originSslProtocols: [aws.edge.OriginSslPolicy.TLS_V1_2], + }; + } +} + +const app = new App({ + outdir, +}); +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "g12345678-1234", + environmentName, + providerConfig: { + region, + }, +}); + +new LocalBackend(stack, { + path: `${stackName}.tfstate`, +}); + +// Viewer-request function that stamps a marker header on the request so the +// association's effect is directly observable (via TestFunction and, once +// deployed, on the actual viewer response echoed back by the origin). +const cfFunction = new aws.edge.Function(stack, "Function", { + nameSuffix: "distribution-function", + code: aws.edge.FunctionCode.fromInline( + `function handler(event) { + var request = event.request; + request.headers['x-distribution-function'] = { value: 'true' }; + return request; +}`, + ), + registerOutputs: true, + outputName: "function", +}); + +new aws.edge.Distribution(stack, "Dist", { + defaultBehavior: { + origin: new TestOrigin("www.example.com"), + cachePolicy: aws.edge.ManagedCachePolicy.CACHING_DISABLED, + functionAssociations: [ + { + function: cfFunction, + eventType: aws.edge.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + registerOutputs: true, + outputName: "distribution", +}); + +app.synth(); diff --git a/integ/aws/edge/edge_test.go b/integ/aws/edge/edge_test.go index 49329deb..ee9e33ef 100644 --- a/integ/aws/edge/edge_test.go +++ b/integ/aws/edge/edge_test.go @@ -4,10 +4,12 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" + cftypes "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" "github.com/aws/aws-sdk-go-v2/service/servicediscovery/types" "github.com/stretchr/testify/require" "github.com/terraconstructs/base/integ" @@ -58,6 +60,14 @@ func TestDistributionPolicies(t *testing.T) { }) } +// Run the apps/distribution-function.ts integration test +// ref: https://github.com/TerraConstructs/base/issues/50 +// ref: https://github.com/TerraConstructs/base/issues/99 +func TestDistributionFunction(t *testing.T) { + envVars := executors.EnvMap(os.Environ()) + runEdgeIntegrationTest(t, "distribution-function", "us-east-1", envVars, validateDistributionFunction) +} + // Test the apps/service-with-http-namespace.ts app // ref: https://github.com/aws/aws-cdk/blob/v2.233.0/packages/@aws-cdk-testing/framework-integ/test/aws-servicediscovery/test/integ.service-with-http-namespace.lit.ts func TestServiceWithHttpNamespace(t *testing.T) { @@ -187,6 +197,73 @@ func validateURLRewriteFunction(t *testing.T, workingDir string, _awsRegion stri } } +// validateDistributionFunction verifies the aws.edge.Distribution's +// defaultBehavior functionAssociations wiring from apps/distribution-function.ts: +// it waits for the distribution to deploy, then confirms the associated +// viewer-request CloudFront Function actually runs by exercising the +// TestFunction API and asserting the marker header it stamps on the request. +func validateDistributionFunction(t *testing.T, workingDir string, awsRegion string) { + // Load the Terraform Options saved by the earlier deploy_terraform stage + terraformOptions := test_structure.LoadTerraformOptions(t, workingDir) + + distributionId := util.LoadOutputAttribute(t, terraformOptions, "distribution", "id") + util.WaitForDistributionDeployed(t, awsRegion, distributionId, 10, 10*time.Second) + + functionName := util.LoadOutputAttribute(t, terraformOptions, "function", "name") + + // Assert the deployed distribution config actually carries the + // viewer-request FunctionAssociation for the function -- this is the + // direct regression check for #99/#50 (a dropped association would + // still let the distribution deploy and the TestFunction call below + // would still succeed, since TestFunction invokes the function by name + // independent of any distribution). + dist, err := util.GetDistributionE(t, awsRegion, distributionId) + require.NoError(t, err) + functionAssociations := dist.DistributionConfig.DefaultCacheBehavior.FunctionAssociations + require.NotNil(t, functionAssociations) + require.EqualValues(t, 1, aws.ToInt32(functionAssociations.Quantity)) + require.Len(t, functionAssociations.Items, 1) + require.Equal(t, cftypes.EventTypeViewerRequest, functionAssociations.Items[0].EventType) + functionArn := aws.ToString(functionAssociations.Items[0].FunctionARN) + require.NotEmpty(t, functionArn) + require.True(t, strings.HasSuffix(functionArn, functionName), + "expected FunctionARN %q to end with function name %q", functionArn, functionName) + + functionStage := "LIVE" + testEvent := &util.CloudFrontFunctionEvent{ + Version: "1.0", + Context: util.Context{ + DistributionDomainName: "d111111abcdef8.cloudfront.net", + DistributionID: distributionId, + EventType: "viewer-request", + RequestID: "test-request-id", + }, + Viewer: util.Viewer{ + IP: "1.2.3.4", + }, + Request: &util.Request{ + Method: "GET", + URI: "/", + Querystring: util.ValueObject{}, + Headers: util.ValueObject{ + "host": util.ValueEntry{Value: "d111111abcdef8.cloudfront.net"}, + }, + }, + } + util.TestCloudFrontFunctionWithCustomValidation(t, functionName, functionStage, *testEvent, + func(r *util.CloudFrontTestFunctionResult) error { + if r.Output == nil { + return fmt.Errorf("got nil Output response") + } + return integ.AssertE(r.Output, []integ.Assertion{ + { + Path: "request.headers.\"x-distribution-function\".value", + ExpectedRegexp: strPtr("^true$"), + }, + }) + }) +} + // validateJwtVerifyFunction with testevents func validateJwtVerifyFunction(t *testing.T, workingDir string, _awsRegion string) { // Load the Terraform Options saved by the earlier deploy_terraform stage diff --git a/src/aws/compute/function-base.ts b/src/aws/compute/function-base.ts index c8a4f1e5..3dd5294a 100644 --- a/src/aws/compute/function-base.ts +++ b/src/aws/compute/function-base.ts @@ -372,6 +372,7 @@ export abstract class LambdaFunctionBase sourceArn: permission.sourceArn ?? sourceArn, principalOrgId: permission.organizationId ?? principalOrgID, functionUrlAuthType: permission.functionUrlAuthType, + invokedViaFunctionUrl: permission.invokedViaFunctionUrl, }); } diff --git a/src/aws/compute/function-permission.ts b/src/aws/compute/function-permission.ts index 499d43ce..def025e8 100644 --- a/src/aws/compute/function-permission.ts +++ b/src/aws/compute/function-permission.ts @@ -88,4 +88,18 @@ export interface Permission { * @default - No functionUrlAuthType */ readonly functionUrlAuthType?: FunctionUrlAuthType; + + /** + * Restricts this permission to only apply to invocations that go through a + * Lambda Function URL (i.e. adds the `lambda:InvokedViaFunctionUrl` + * condition key to the generated resource policy statement). + * + * This is used, for example, to scope the `lambda:InvokeFunction` + * permission that is required (in addition to `lambda:InvokeFunctionUrl`) + * for a public (`FunctionUrlAuthType.NONE`) function URL, mirroring the + * `FunctionURLInvokeAllowPublicAccess` statement the AWS Console adds. + * + * @default - no lambda:InvokedViaFunctionUrl condition is added to the statement + */ + readonly invokedViaFunctionUrl?: boolean; } diff --git a/src/aws/compute/function-url.ts b/src/aws/compute/function-url.ts index a72c75a2..66d8e278 100644 --- a/src/aws/compute/function-url.ts +++ b/src/aws/compute/function-url.ts @@ -260,12 +260,27 @@ export class FunctionUrl extends AwsConstructBase implements IFunctionUrl { this.functionArn = this.resource.functionArn; this.function = props.function; - if (props.authType === FunctionUrlAuthType.NONE) { + if (this.authType === FunctionUrlAuthType.NONE) { props.function.addPermission("invoke-function-url", { principal: new iam.AnyPrincipal(), action: "lambda:InvokeFunctionUrl", functionUrlAuthType: props.authType, }); + // A public (authType NONE) Function URL also requires a standalone + // lambda:InvokeFunction grant - lambda:InvokeFunctionUrl alone is not + // sufficient and unauthenticated callers otherwise receive a 403. + // This mirrors the "FunctionURLInvokeAllowPublicAccess" statement the AWS + // Console/CLI add automatically, which is scoped down using the + // lambda:InvokedViaFunctionUrl condition key so this permission only + // applies to invocations made through the function URL (not direct + // lambda:InvokeFunction calls). The `invoked_via_function_url` + // argument on aws_lambda_permission maps 1:1 to that condition key. + // See: https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html + props.function.addPermission("invoke-function-url-via-invoke", { + principal: new iam.AnyPrincipal(), + action: "lambda:InvokeFunction", + invokedViaFunctionUrl: true, + }); } this.functionUrlOutputs = { url: this.url, diff --git a/src/aws/edge/distribution.ts b/src/aws/edge/distribution.ts index ca98816c..c65c9ce8 100644 --- a/src/aws/edge/distribution.ts +++ b/src/aws/edge/distribution.ts @@ -5,9 +5,16 @@ import { dataAwsCloudfrontOriginRequestPolicy, dataAwsCloudfrontResponseHeadersPolicy, } from "@cdktn/provider-aws"; -import { IResolvable, Token, Lazy } from "cdktn"; +import { Annotations, IResolvable, Token, Lazy } from "cdktn"; import { Construct } from "constructs"; -import { ICertificate, IOrigin, FunctionAssociation } from "."; +import { + ICertificate, + IOrigin, + FunctionAssociation, + FunctionEventType, +} from "."; +// aliased to avoid shadowing the global `Function` constructor +import { Function as CloudFrontFunction } from "./function"; import { Duration } from "../../duration"; import { ArnFormat } from "../arn"; import { @@ -243,6 +250,7 @@ export class Distribution extends AwsConstructBase implements IDistribution { private readonly errorResponses: ErrorResponse[]; private readonly certificate?: ICertificate; + private readonly warnedUnpublishedFunctions = new Set(); constructor(scope: Construct, name: string, props: DistributionProps) { super(scope, name, props); @@ -297,11 +305,30 @@ export class Distribution extends AwsConstructBase implements IDistribution { ), ), }), - defaultCacheBehavior: this._renderDefaultCacheBehavior({ - pathPattern: "*", // ignored for Default Cache Behavior - targetOriginId: defaultOriginId, - ...props.defaultBehavior, - }), + defaultCacheBehavior: { + ...this._renderDefaultCacheBehavior({ + pathPattern: "*", // ignored for Default Cache Behavior + targetOriginId: defaultOriginId, + ...props.defaultBehavior, + // rendered lazily below so associations pushed onto a caller-held + // array *after* construction are still picked up at synth time + functionAssociations: undefined, + }), + functionAssociation: Lazy.anyValue( + { + produce: () => + this.renderFunctionAssociations( + props.defaultBehavior.functionAssociations, + )?.map((fa) => + // Lazy producers need additional xxxToTerraform wrap + cloudfrontDistribution.cloudfrontDistributionDefaultCacheBehaviorFunctionAssociationToTerraform( + fa, + ), + ), + }, + { omitEmptyArray: true }, + ), + }, orderedCacheBehavior: Lazy.anyValue( { produce: () => @@ -480,9 +507,67 @@ export class Distribution extends AwsConstructBase implements IDistribution { smoothStreaming: props.smoothStreaming, viewerProtocolPolicy: props.viewerProtocolPolicy ?? ViewerProtocolPolicy.ALLOW_ALL, + functionAssociation: this.renderFunctionAssociations( + props.functionAssociations, + ), }; } + /** + * Renders the `functionAssociation` blocks for a cache behavior from the + * given `FunctionAssociation`s. + * + * CloudFront allows at most one function association per `FunctionEventType` + * for each cache behavior. + * + * @internal + */ + private renderFunctionAssociations( + functionAssociations?: FunctionAssociation[], + ): + | cloudfrontDistribution.CloudfrontDistributionDefaultCacheBehaviorFunctionAssociation[] + | undefined { + if (!functionAssociations || functionAssociations.length === 0) { + return undefined; + } + const eventTypes = new Set(); + for (const fa of functionAssociations) { + if (eventTypes.has(fa.eventType)) { + throw new Error( + `Only one function association is allowed per event type, got multiple for event type ${fa.eventType}`, + ); + } + eventTypes.add(fa.eventType); + // Only locally-created `Function`s are verifiable here - imported/general + // `IFunction` implementations may or may not be published, so leave them + // alone. CloudFront only allows LIVE-stage (published) functions to be + // associated with a distribution's cache behaviors. + // Lazy producers resolve more than once per synth (prepareStack + + // final render), so dedupe to avoid stacking identical warnings on + // this node's metadata. + if ( + CloudFrontFunction.isFunction(fa.function) && + !fa.function._autoPublish && + !fa.skipPublishCheck && + !this.warnedUnpublishedFunctions.has(fa.function.node.path) + ) { + this.warnedUnpublishedFunctions.add(fa.function.node.path); + // TODO(https://github.com/TerraConstructs/base/issues/161): switch to + // Annotations.addWarningV2()/acknowledgeWarning() once the Annotations + // facade lands, using the id prefix below as the warning's stable id. + Annotations.of(this).addWarning( + `[terraconstructs/aws-edge:unpublishedFunctionAssociation] Function '${fa.function.node.path}' is associated with a cache behavior but was created with autoPublish: false; ` + + "CloudFront only allows LIVE-stage functions in cache behaviors, so this will fail at apply time unless the function is published out of band. " + + "Set skipPublishCheck: true on the association to acknowledge.", + ); + } + } + return functionAssociations.map((fa) => ({ + eventType: fa.eventType, + functionArn: fa.function.functionArn, + })); + } + private renderRestrictions(geoRestriction?: GeoRestriction) { return geoRestriction ? { diff --git a/src/aws/edge/function.ts b/src/aws/edge/function.ts index 62d292aa..8032010c 100644 --- a/src/aws/edge/function.ts +++ b/src/aws/edge/function.ts @@ -10,12 +10,27 @@ import { // ref: https://github.com/aws/aws-cdk/blob/v2.156.0/packages/aws-cdk-lib/aws-cloudfront/lib/function.ts +const EDGE_FUNCTION_SYMBOL = Symbol.for( + "terraconstructs/lib/aws/edge.Function", +); + /** * Represents the function's source code */ export abstract class FunctionCode { /** * Inline code for function + * + * Note: unlike `fromFile`, this does NOT escape `${` sequences in the + * provided `code`. The string is emitted verbatim into the synthesized + * Terraform JSON, where cdktn tokens resolve during synth and any remaining + * `${...}` sequences are then interpreted by Terraform as interpolation + * expressions at plan time. This is intentional, since inline code is + * frequently built up using template literals/tokens. If your inline code + * legitimately contains a literal `${` (e.g. JavaScript template literal + * syntax used by the CloudFront Function itself), escape it as `$${` + * before passing it in. + * * @returns code object with inline code. * @param code The actual function code */ @@ -175,6 +190,17 @@ export interface FunctionProps extends AwsConstructProps { */ export class Function extends AwsConstructBase implements IFunction { // TODO: Add static fromLookup? + + /** + * Return whether the given object is a Function. + * + * Uses a symbol-based runtime check instead of `instanceof` so the + * identification survives duplicate copies of this library (e.g. multiple + * installed versions), matching `AwsStack.isAwsStack`/`Role.isRole`. + */ + public static isFunction(x: any): x is Function { + return x !== null && typeof x === "object" && EDGE_FUNCTION_SYMBOL in x; + } public readonly resource: cloudfrontFunction.CloudfrontFunction; private readonly _outputs: FunctionOutputs; @@ -205,6 +231,17 @@ export class Function extends AwsConstructBase implements IFunction { */ public readonly functionRuntime: string; + /** + * Whether this function is automatically published to the LIVE stage on + * creation. CloudFront only allows LIVE-stage functions to be associated + * with a distribution's cache behaviors, so consumers (e.g. `Distribution`) + * use this to fail fast when a function that opted out of auto-publish is + * associated with a cache behavior. + * + * @internal + */ + public readonly _autoPublish: boolean; + constructor(scope: Construct, id: string, props: FunctionProps) { super(scope, id, props); @@ -235,11 +272,13 @@ export class Function extends AwsConstructBase implements IFunction { ); } + this._autoPublish = props.autoPublish ?? true; + this.resource = new cloudfrontFunction.CloudfrontFunction( this, "Resource", { - publish: props.autoPublish ?? true, + publish: this._autoPublish, code: props.code.render(), comment: props.comment ?? this.functionName, runtime: this.functionRuntime, @@ -247,6 +286,15 @@ export class Function extends AwsConstructBase implements IFunction { ? [props.keyValueStore.arn] : undefined, name: this.functionName, + // CloudFront Functions cannot be deleted while still associated with + // a distribution's cache behavior. When a name change forces + // replacement, destroy-before-create would fail with a + // `FunctionInUse` error, so the replacement function must be created + // (and the distribution updated to reference it) before the old one + // is destroyed. + lifecycle: { + createBeforeDestroy: true, + }, }, ); @@ -287,6 +335,18 @@ export interface FunctionAssociation { /** The type of event which should invoke the function. */ readonly eventType: FunctionEventType; + + /** + * Set this ONLY IF this function's publication to the LIVE stage is managed outside of this + * stack (e.g. by a pipeline or a later apply that flips autoPublish). You are acknowledging + * that the distribution will fail to deploy if the function is not LIVE at apply time. + * + * Has no effect for imported functions - they are never checked, since whether or not they + * are published is not knowable from an `IFunction` reference. + * + * @default false + */ + readonly skipPublishCheck?: boolean; } /** @@ -304,3 +364,9 @@ export enum FunctionRuntime { */ JS_2_0 = "cloudfront-js-2.0", } + +Object.defineProperty(Function.prototype, EDGE_FUNCTION_SYMBOL, { + value: true, + enumerable: false, + writable: false, +}); diff --git a/test/aws/compute/function-url.test.ts b/test/aws/compute/function-url.test.ts new file mode 100644 index 00000000..e9654695 --- /dev/null +++ b/test/aws/compute/function-url.test.ts @@ -0,0 +1,122 @@ +import { lambdaFunctionUrl, lambdaPermission } from "@cdktn/provider-aws"; +import { Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { compute, AwsStack } from "../../../src/aws"; +import { Template } from "../../assertions"; + +const gridUUID = "a123e456-e89b-12d3"; + +describe("FunctionUrl", () => { + let stack: AwsStack; + let fn: compute.LambdaFunction; + beforeEach(() => { + stack = new AwsStack(Testing.app(), "MyStack", { + gridUUID, + }); + fn = new compute.LambdaFunction(stack, "MyLambda", { + code: new compute.InlineCode("hello()"), + handler: "index.hello", + runtime: compute.Runtime.NODEJS_LATEST, + }); + }); + + test("authType NONE adds both InvokeFunctionUrl and InvokeFunction permissions", () => { + // WHEN + new compute.FunctionUrl(stack, "Url", { + function: fn, + authType: compute.FunctionUrlAuthType.NONE, + }); + + // THEN + stack.prepareStack(); + const synthesized = Testing.synth(stack); + const template = new Template(stack); + + expect(synthesized).toHaveResourceWithProperties( + lambdaFunctionUrl.LambdaFunctionUrl, + { + authorization_type: "NONE", + function_name: "${aws_lambda_function.MyLambda_CCE802FB.arn}", + }, + ); + + // lambda:InvokeFunctionUrl - required for the function URL invocation itself + expect(synthesized).toHaveResourceWithProperties( + lambdaPermission.LambdaPermission, + { + action: "lambda:InvokeFunctionUrl", + principal: "*", + function_name: "${aws_lambda_function.MyLambda_CCE802FB.arn}", + function_url_auth_type: "NONE", + }, + ); + + // lambda:InvokeFunction - also required, scoped to function-url invocations, + // matching AWS's FunctionURLAllowInvokeAction console statement. + expect(synthesized).toHaveResourceWithProperties( + lambdaPermission.LambdaPermission, + { + action: "lambda:InvokeFunction", + principal: "*", + function_name: "${aws_lambda_function.MyLambda_CCE802FB.arn}", + invoked_via_function_url: true, + }, + ); + + template.resourceCountIs(lambdaPermission.LambdaPermission, 2); + }); + + test("authType AWS_IAM does not add any InvokeFunctionUrl/InvokeFunction permissions", () => { + // WHEN + new compute.FunctionUrl(stack, "Url", { + function: fn, + authType: compute.FunctionUrlAuthType.AWS_IAM, + }); + + // THEN + stack.prepareStack(); + const synthesized = Testing.synth(stack); + const template = new Template(stack); + + expect(synthesized).toHaveResourceWithProperties( + lambdaFunctionUrl.LambdaFunctionUrl, + { + authorization_type: "AWS_IAM", + function_name: "${aws_lambda_function.MyLambda_CCE802FB.arn}", + }, + ); + + template.resourceCountIs(lambdaPermission.LambdaPermission, 0); + }); + + test("authType NONE on an alias-qualified url still adds both permissions", () => { + // GIVEN + const alias = new compute.Alias(stack, "Alias", { + aliasName: "prod", + function: fn, + version: fn.version, + }); + + // WHEN + new compute.FunctionUrl(stack, "Url", { + function: alias, + authType: compute.FunctionUrlAuthType.NONE, + }); + + // THEN + stack.prepareStack(); + const synthesized = Testing.synth(stack); + const template = new Template(stack); + + expect(synthesized).toHaveResourceWithProperties( + lambdaFunctionUrl.LambdaFunctionUrl, + { + authorization_type: "NONE", + function_name: "${aws_lambda_function.MyLambda_CCE802FB.arn}", + qualifier: `${gridUUID}-prod`, + }, + ); + + template.resourceCountIs(lambdaPermission.LambdaPermission, 2); + }); +}); diff --git a/test/aws/edge/__snapshots__/function.test.ts.snap b/test/aws/edge/__snapshots__/function.test.ts.snap new file mode 100644 index 00000000..3397c482 --- /dev/null +++ b/test/aws/edge/__snapshots__/function.test.ts.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Function Should synth and match SnapShot 1`] = ` +"{ + "data": { + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + }, + "aws_region": { + "Region": { + "provider": "aws" + } + } + }, + "provider": { + "aws": [ + {} + ] + }, + "resource": { + "aws_cloudfront_function": { + "Function_76856677": { + "code": "whatever", + "comment": "Hello World", + "lifecycle": { + "create_before_destroy": true + }, + "name": "g-hello-world", + "publish": true, + "runtime": "cloudfront-js-1.0", + "tags": { + "Name": "Default-Function", + "grid:EnvironmentName": "Default", + "grid:UUID": "g" + } + } + } + }, + "terraform": { + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/edge/__snapshots__/key-value-store.test.ts.snap b/test/aws/edge/__snapshots__/key-value-store.test.ts.snap index 60355cc7..43793bfe 100644 --- a/test/aws/edge/__snapshots__/key-value-store.test.ts.snap +++ b/test/aws/edge/__snapshots__/key-value-store.test.ts.snap @@ -32,6 +32,9 @@ exports[`KeyValueStore Should associate with edge.Function and match SnapShot 1` "key_value_store_associations": [ "\${aws_cloudfront_key_value_store.Store_1D2A845B.arn}" ], + "lifecycle": { + "create_before_destroy": true + }, "name": "g-hello-world", "publish": true, "runtime": "cloudfront-js-2.0", diff --git a/test/aws/edge/distribution.test.ts b/test/aws/edge/distribution.test.ts index b34d59b8..7782ee69 100644 --- a/test/aws/edge/distribution.test.ts +++ b/test/aws/edge/distribution.test.ts @@ -5,7 +5,7 @@ import { import { App, HttpBackend, Testing } from "cdktn"; import "cdktn/lib/testing/adapters/jest"; import { edge, storage, AwsStack } from "../../../src/aws"; -import { Template } from "../../assertions"; +import { Annotations, Template } from "../../assertions"; const gridBackendConfig = { address: "http://localhost:3000", @@ -137,6 +137,451 @@ describe("Distribution", () => { }, }); }); + test("Should render functionAssociations on default and ordered cache behaviors", () => { + // GIVEN + const bucket0 = new storage.Bucket(stack, "Bucket0", { + namePrefix: "bucket-0", + cloudfrontAccess: { + enabled: true, + }, + }); + const bucket1 = new storage.Bucket(stack, "Bucket1", { + namePrefix: "bucket-1", + cloudfrontAccess: { + enabled: true, + }, + }); + const viewerRequestFn = new edge.Function(stack, "ViewerRequestFn", { + nameSuffix: "viewer-request", + code: edge.FunctionCode.fromInline("whatever"), + }); + const viewerResponseFn = new edge.Function(stack, "ViewerResponseFn", { + nameSuffix: "viewer-response", + code: edge.FunctionCode.fromInline("whatever"), + }); + // WHEN + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket0), + functionAssociations: [ + { + function: viewerRequestFn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + additionalBehaviors: { + "/images/*": { + origin: new edge.S3Origin(bucket1), + functionAssociations: [ + { + function: viewerResponseFn, + eventType: edge.FunctionEventType.VIEWER_RESPONSE, + }, + ], + }, + }, + }); + // THEN + Template.fromStack(stack).toMatchObject({ + resource: { + aws_cloudfront_distribution: { + HelloWorldDistribution_E7735130: { + default_cache_behavior: { + function_association: [ + { + event_type: "viewer-request", + function_arn: stack.resolve(viewerRequestFn.functionArn), + }, + ], + }, + ordered_cache_behavior: [ + { + path_pattern: "/images/*", + function_association: [ + { + event_type: "viewer-response", + function_arn: stack.resolve(viewerResponseFn.functionArn), + }, + ], + }, + ], + }, + }, + }, + }); + }); + test("Should throw on duplicate function association event types", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "duplicate", + code: edge.FunctionCode.fromInline("whatever"), + }); + // WHEN - default behavior renders lazily, so the error surfaces at synth + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }, + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + }); + // THEN + expect(() => { + Template.fromStack(stack); + }).toThrow("Only one function association is allowed per event type"); + }); + test("Should include functionAssociations pushed onto the array after construction", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "late-push", + code: edge.FunctionCode.fromInline("whatever"), + }); + const functionAssociations: edge.FunctionAssociation[] = []; + // WHEN + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations, + }, + }); + // Associations pushed onto the caller-held array *after* construction + // must still be picked up, since rendering is deferred to synth time. + functionAssociations.push({ + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }); + // THEN + Template.fromStack(stack).toMatchObject({ + resource: { + aws_cloudfront_distribution: { + HelloWorldDistribution_E7735130: { + default_cache_behavior: { + function_association: [ + { + event_type: "viewer-request", + function_arn: stack.resolve(fn.functionArn), + }, + ], + }, + }, + }, + }, + }); + }); + test("Should throw on duplicate function association event types in additional behaviors", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "duplicate", + code: edge.FunctionCode.fromInline("whatever"), + }); + // WHEN - additionalBehaviors render lazily, so the error surfaces at synth + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + }, + additionalBehaviors: { + "/images/*": { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_RESPONSE, + }, + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_RESPONSE, + }, + ], + }, + }, + }); + // THEN + expect(() => { + Template.fromStack(stack); + }).toThrow("Only one function association is allowed per event type"); + }); + test("Should warn when associating a function created with autoPublish: false", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "unpublished", + code: edge.FunctionCode.fromInline("whatever"), + autoPublish: false, + }); + // WHEN + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + }); + // THEN - no longer a hard failure, just an acknowledgeable warning + expect(() => { + Template.fromStack(stack); + }).not.toThrow(); + Annotations.fromStack(stack).hasWarnings({ + message: /unpublishedFunctionAssociation/, + }); + }); + test("Should warn when associating an autoPublish: false function via additional behaviors", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "unpublished", + code: edge.FunctionCode.fromInline("whatever"), + autoPublish: false, + }); + // WHEN + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + }, + additionalBehaviors: { + "/images/*": { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + }, + }); + // THEN + expect(() => { + Template.fromStack(stack); + }).not.toThrow(); + Annotations.fromStack(stack).hasWarnings({ + message: /unpublishedFunctionAssociation/, + }); + }); + test("skipPublishCheck: true suppresses the unpublished-function warning", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "unpublished", + code: edge.FunctionCode.fromInline("whatever"), + autoPublish: false, + }); + // WHEN - the caller acknowledges that publication is managed elsewhere + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + skipPublishCheck: true, + }, + ], + }, + }); + // THEN + expect(() => { + Template.fromStack(stack); + }).not.toThrow(); + Annotations.fromStack(stack).hasNoWarnings({ + message: /unpublishedFunctionAssociation/, + }); + }); + test("skipPublishCheck only suppresses the acknowledged association, not others", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const acknowledgedFn = new edge.Function(stack, "AcknowledgedFn", { + nameSuffix: "acknowledged", + code: edge.FunctionCode.fromInline("whatever"), + autoPublish: false, + }); + const unacknowledgedFn = new edge.Function(stack, "UnacknowledgedFn", { + nameSuffix: "unacknowledged", + code: edge.FunctionCode.fromInline("whatever"), + autoPublish: false, + }); + // WHEN - only the first association acknowledges out-of-band publication + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: acknowledgedFn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + skipPublishCheck: true, + }, + { + function: unacknowledgedFn, + eventType: edge.FunctionEventType.VIEWER_RESPONSE, + }, + ], + }, + }); + // THEN - synth first so the lazily-rendered behaviors emit their warnings + Template.fromStack(stack); + const annotations = Annotations.fromStack(stack); + annotations.hasWarnings({ + message: /unpublishedFunctionAssociation.*UnacknowledgedFn/, + }); + annotations.hasNoWarnings({ + message: /unpublishedFunctionAssociation.*AcknowledgedFn/, + }); + }); + test("Should still warn for a late-pushed association with an unpublished function", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "late-unpublished", + code: edge.FunctionCode.fromInline("whatever"), + autoPublish: false, + }); + const functionAssociations: edge.FunctionAssociation[] = []; + // WHEN + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations, + }, + }); + // Pushed onto the caller-held array *after* construction - the warning + // check must still see it, since it is deferred to synth time. + functionAssociations.push({ + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }); + // THEN + expect(() => { + Template.fromStack(stack); + }).not.toThrow(); + Annotations.fromStack(stack).hasWarnings({ + message: /unpublishedFunctionAssociation/, + }); + }); + test("Should allow associating a function created with autoPublish: true (default)", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + const fn = new edge.Function(stack, "Fn", { + nameSuffix: "published", + code: edge.FunctionCode.fromInline("whatever"), + autoPublish: true, + }); + // WHEN + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: fn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + }); + // THEN + expect(() => { + Template.fromStack(stack); + }).not.toThrow(); + Annotations.fromStack(stack).hasNoWarnings({ + message: /unpublishedFunctionAssociation/, + }); + }); + test("Should not reject an imported (non-Function) IFunction association", () => { + // GIVEN + const bucket = new storage.Bucket(stack, "Bucket", { + namePrefix: "bucket", + cloudfrontAccess: { + enabled: true, + }, + }); + // An imported/general IFunction implementation is unverifiable and + // therefore must not be rejected, even though it can't be proven to be + // published to the LIVE stage. + // NOTE: edge.Function has no static import method yet (see the + // `TODO: Add static fromLookup?` in src/aws/edge/function.ts); when one + // is added, switch this cast to use it. + const importedFn: edge.IFunction = { + functionArn: + "arn:aws:cloudfront::123456789012:function/imported-function", + } as unknown as edge.IFunction; + // WHEN + new edge.Distribution(stack, "HelloWorldDistribution", { + defaultBehavior: { + origin: new edge.S3Origin(bucket), + functionAssociations: [ + { + function: importedFn, + eventType: edge.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + }); + // THEN + expect(() => { + Template.fromStack(stack); + }).not.toThrow(); + Annotations.fromStack(stack).hasNoWarnings({ + message: /unpublishedFunctionAssociation/, + }); + }); test("Should support custom Response Header Policy", () => { // GIVEN const bucket = new storage.Bucket(stack, "Bucket", { diff --git a/test/aws/edge/fixtures/function-code.js b/test/aws/edge/fixtures/function-code.js new file mode 100644 index 00000000..bdee6543 --- /dev/null +++ b/test/aws/edge/fixtures/function-code.js @@ -0,0 +1,5 @@ +function handler(event) { + var request = event.request; + var greeting = `Hello, ${request.uri}!`; + return request; +} diff --git a/test/aws/edge/function.test.ts b/test/aws/edge/function.test.ts new file mode 100644 index 00000000..e390bfd7 --- /dev/null +++ b/test/aws/edge/function.test.ts @@ -0,0 +1,102 @@ +import * as fs from "fs"; +import * as path from "path"; +import { cloudfrontFunction } from "@cdktn/provider-aws"; +import { Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { edge, AwsStack } from "../../../src/aws"; +import { Template } from "../../assertions"; + +describe("FunctionCode", () => { + test("FileCode escapes ${ to $${ when rendering", () => { + // GIVEN + const filePath = path.join(__dirname, "fixtures", "function-code.js"); + const original = fs.readFileSync(filePath, { encoding: "utf-8" }); + // sanity check the fixture actually contains an unescaped `${` + expect(original).toContain("${request.uri}"); + // WHEN + const rendered = edge.FunctionCode.fromFile({ filePath }).render(); + // THEN + expect(rendered).toBe(original.replace(/\$\{/g, "$$${")); + expect(rendered).toContain("$${request.uri}"); + }); + + test("InlineCode passes content through unchanged", () => { + // GIVEN + const code = "Hello, ${request.uri}! And a newline.\n"; + // WHEN + const rendered = edge.FunctionCode.fromInline(code).render(); + // THEN + expect(rendered).toBe(code); + }); +}); + +describe("Function", () => { + test("Should set create_before_destroy lifecycle on the resource", () => { + // GIVEN + const stack = new AwsStack(); + // WHEN + new edge.Function(stack, "Function", { + nameSuffix: "hello-world", + code: edge.FunctionCode.fromInline("whatever"), + }); + // THEN + Template.synth(stack).toHaveResourceWithProperties( + cloudfrontFunction.CloudfrontFunction, + { + lifecycle: { + create_before_destroy: true, + }, + }, + ); + }); + + test("Should keep FileCode `${` escaped as `$${` in the synthesized code", () => { + // GIVEN + const stack = new AwsStack(); + // WHEN + new edge.Function(stack, "Function", { + nameSuffix: "hello-world", + code: edge.FunctionCode.fromFile({ + filePath: path.join(__dirname, "fixtures", "function-code.js"), + }), + }); + // THEN + Template.synth(stack).toHaveResourceWithProperties( + cloudfrontFunction.CloudfrontFunction, + { + code: expect.stringContaining("$${request.uri}"), + }, + ); + }); + + test("isFunction identifies Function instances via symbol, not instanceof", () => { + // GIVEN + const stack = new AwsStack(); + const fn = new edge.Function(stack, "Function", { + nameSuffix: "hello-world", + code: edge.FunctionCode.fromInline("whatever"), + }); + // THEN + expect(edge.Function.isFunction(fn)).toBe(true); + expect(edge.Function.isFunction(undefined)).toBe(false); + expect(edge.Function.isFunction(null)).toBe(false); + expect( + edge.Function.isFunction({ + functionArn: "arn:aws:cloudfront::123456789012:function/imported", + }), + ).toBe(false); + }); + + test("Should synth and match SnapShot", () => { + // GIVEN + const stack = new AwsStack(); + // WHEN + new edge.Function(stack, "Function", { + nameSuffix: "hello-world", + comment: "Hello World", + code: edge.FunctionCode.fromInline("whatever"), + }); + // THEN + expect(Testing.synth(stack)).toMatchSnapshot(); + }); +});