From 2ce3b72a9c5ba72d10836dd88928daf3a66c19dc Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sun, 16 Aug 2026 13:21:59 +0700 Subject: [PATCH 1/5] fix(edge): render functionAssociations and create CloudFront Functions before destroy Distribution silently dropped functionAssociations: the prop was declared but never rendered into the aws_cloudfront_distribution function_association blocks, for the default and ordered cache behaviors alike. Render it, and validate at most one association per event type per behavior. Set create_before_destroy on aws_cloudfront_function so a name-forced replacement no longer fails with FunctionInUse while the function is attached to a distribution. Add regression tests for FunctionCode.fromFile ${ -> $${ escaping (including a synth-level assertion) and document that fromInline intentionally passes ${ through for token interpolation. Port the upstream aws-cdk integ.distribution-function.ts integration test; it asserts the deployed distribution's FunctionAssociations via the CloudFront API. Verified live: TestDistributionFunction passed. Closes #99 Closes #50 --- integ/aws/edge/Makefile | 4 + integ/aws/edge/README.md | 2 + integ/aws/edge/apps/distribution-function.ts | 75 +++++++++ integ/aws/edge/edge_test.go | 77 ++++++++++ src/aws/edge/distribution.ts | 42 ++++- src/aws/edge/function.ts | 20 +++ .../edge/__snapshots__/function.test.ts.snap | 55 +++++++ .../key-value-store.test.ts.snap | 3 + test/aws/edge/distribution.test.ts | 143 ++++++++++++++++++ test/aws/edge/fixtures/function-code.js | 5 + test/aws/edge/function.test.ts | 84 ++++++++++ 11 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 integ/aws/edge/apps/distribution-function.ts create mode 100644 test/aws/edge/__snapshots__/function.test.ts.snap create mode 100644 test/aws/edge/fixtures/function-code.js create mode 100644 test/aws/edge/function.test.ts 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/edge/distribution.ts b/src/aws/edge/distribution.ts index ca98816c..d67ed352 100644 --- a/src/aws/edge/distribution.ts +++ b/src/aws/edge/distribution.ts @@ -7,7 +7,12 @@ import { } from "@cdktn/provider-aws"; import { IResolvable, Token, Lazy } from "cdktn"; import { Construct } from "constructs"; -import { ICertificate, IOrigin, FunctionAssociation } from "."; +import { + ICertificate, + IOrigin, + FunctionAssociation, + FunctionEventType, +} from "."; import { Duration } from "../../duration"; import { ArnFormat } from "../arn"; import { @@ -480,9 +485,44 @@ 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); + } + 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..ef0b7808 100644 --- a/src/aws/edge/function.ts +++ b/src/aws/edge/function.ts @@ -16,6 +16,17 @@ import { 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 */ @@ -247,6 +258,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, + }, }, ); 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..0a2ed17f 100644 --- a/test/aws/edge/distribution.test.ts +++ b/test/aws/edge/distribution.test.ts @@ -137,6 +137,149 @@ 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"), + }); + // THEN + expect(() => { + 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, + }, + ], + }, + }); + }).toThrow("Only one function association is allowed per event type"); + }); + 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 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..75a89aeb --- /dev/null +++ b/test/aws/edge/function.test.ts @@ -0,0 +1,84 @@ +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("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(); + }); +}); From 387c9986ad8a0c2c794b4c814d1d4bd5c80b31ee Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sun, 16 Aug 2026 13:22:07 +0700 Subject: [PATCH 2/5] fix(compute): grant lambda:InvokeFunction for public Function URLs FunctionUrl with authType NONE only added lambda:InvokeFunctionUrl, so every public Function URL returned 403 AccessDeniedException. AWS requires a second lambda:InvokeFunction statement (the console's FunctionURLInvokeAllowPublicAccess). The grant is scoped with the lambda:InvokedViaFunctionUrl condition key via the provider's invoked_via_function_url argument, exposed as a new optional invokedViaFunctionUrl prop on Permission, so it only applies to invocations arriving through the Function URL. Verified live: TestNodeJsFunctionUrl passed (public URL returns 200). Closes #98 --- src/aws/compute/function-base.ts | 1 + src/aws/compute/function-permission.ts | 14 +++ src/aws/compute/function-url.ts | 17 +++- test/aws/compute/function-url.test.ts | 122 +++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 test/aws/compute/function-url.test.ts 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/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); + }); +}); From 27d35c0add3946d6ab8b883cd3478d985c0fc270 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sun, 16 Aug 2026 16:38:02 +0700 Subject: [PATCH 3/5] fix(edge): render default-behavior functionAssociations lazily and reject unpublished functions Address PR #159 review findings: - The default cache behavior's function_association is now produced via Lazy.anyValue so associations pushed onto a caller-held array after the Distribution constructor are still rendered at synth. The producer wraps each element with the generated ...FunctionAssociationToTerraform mapper (lazy tokens bypass the struct mapper, see #116). Duplicate-event-type validation for the default behavior consequently moves to synth time. - Associating a locally-created edge.Function with autoPublish: false now fails fast at synth for both default and ordered behaviors: CloudFront only allows LIVE-stage functions in cache behaviors, so the config would synth fine and fail at apply. Imported/general IFunction implementations are unverifiable and left alone. Exposed via a jsii-internal _autoPublish member on Function. Re-verified live: TestDistributionFunction passed after the change. --- src/aws/edge/distribution.ts | 44 ++++++- src/aws/edge/function.ts | 15 ++- test/aws/edge/distribution.test.ts | 204 +++++++++++++++++++++++++++-- 3 files changed, 243 insertions(+), 20 deletions(-) diff --git a/src/aws/edge/distribution.ts b/src/aws/edge/distribution.ts index d67ed352..e5221c14 100644 --- a/src/aws/edge/distribution.ts +++ b/src/aws/edge/distribution.ts @@ -13,6 +13,8 @@ import { 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 { @@ -302,11 +304,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: () => @@ -516,6 +537,19 @@ export class Distribution extends AwsConstructBase implements IDistribution { ); } 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. + if ( + fa.function instanceof CloudFrontFunction && + !fa.function._autoPublish + ) { + throw new Error( + `Function '${fa.function.node.path}' is associated with a cache behavior but was created with autoPublish: false. ` + + "CloudFront requires the function to be published (LIVE stage) to be associated with a distribution's cache behavior.", + ); + } } return functionAssociations.map((fa) => ({ eventType: fa.eventType, diff --git a/src/aws/edge/function.ts b/src/aws/edge/function.ts index ef0b7808..410e7559 100644 --- a/src/aws/edge/function.ts +++ b/src/aws/edge/function.ts @@ -216,6 +216,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); @@ -246,11 +257,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, diff --git a/test/aws/edge/distribution.test.ts b/test/aws/edge/distribution.test.ts index 0a2ed17f..6ab1e6ad 100644 --- a/test/aws/edge/distribution.test.ts +++ b/test/aws/edge/distribution.test.ts @@ -223,24 +223,70 @@ describe("Distribution", () => { 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(() => { - 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, + 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), + }, + ], }, - ], + }, }, - }); - }).toThrow("Only one function association is allowed per event type"); + }, + }); }); test("Should throw on duplicate function association event types in additional behaviors", () => { // GIVEN @@ -280,6 +326,136 @@ describe("Distribution", () => { Template.fromStack(stack); }).toThrow("Only one function association is allowed per event type"); }); + test("Should throw 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 + expect(() => { + Template.fromStack(stack); + }).toThrow(/autoPublish: false/); + }); + test("Should throw 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); + }).toThrow(/autoPublish: false/); + }); + 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(); + }); + 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(); + }); test("Should support custom Response Header Policy", () => { // GIVEN const bucket = new storage.Bucket(stack, "Bucket", { From 4d78a07b576abb466dc3734c74e899596de6946d Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sun, 16 Aug 2026 18:05:32 +0700 Subject: [PATCH 4/5] fix(edge): downgrade unpublished-function association error to acknowledgeable warning Per review, the hard synth failure was too restrictive for intentionally separate stage management (publication by a pipeline or a later apply, distribution created only after the function is LIVE). Associating a locally-created edge.Function with autoPublish: false now emits an Annotations warning with the stable id prefix [terraconstructs/aws-edge:unpublishedFunctionAssociation] instead of throwing. Consumers acknowledge intentional out-of-band publication with a new skipPublishCheck flag on the association (skipPermissions-style contract); the acknowledgment is per association, so other accidental unpublished associations still warn. Imported functions remain unchecked. Warnings emitted inside the Lazy behavior producers are captured by the synth manifest (prepareStack resolves lazies before annotation collection; verified empirically) and deduped across the double resolution pass. TODO(#161) tracks migrating to an id-based addWarningV2/acknowledgeWarning Annotations facade. --- src/aws/edge/distribution.ts | 21 +++-- src/aws/edge/function.ts | 12 +++ test/aws/edge/distribution.test.ts | 138 +++++++++++++++++++++++++++-- 3 files changed, 160 insertions(+), 11 deletions(-) diff --git a/src/aws/edge/distribution.ts b/src/aws/edge/distribution.ts index e5221c14..59fccdf9 100644 --- a/src/aws/edge/distribution.ts +++ b/src/aws/edge/distribution.ts @@ -5,7 +5,7 @@ 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, @@ -250,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); @@ -541,13 +542,23 @@ export class Distribution extends AwsConstructBase implements IDistribution { // `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 ( fa.function instanceof CloudFrontFunction && - !fa.function._autoPublish + !fa.function._autoPublish && + !fa.skipPublishCheck && + !this.warnedUnpublishedFunctions.has(fa.function.node.path) ) { - throw new Error( - `Function '${fa.function.node.path}' is associated with a cache behavior but was created with autoPublish: false. ` + - "CloudFront requires the function to be published (LIVE stage) to be associated with a distribution's cache behavior.", + 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.", ); } } diff --git a/src/aws/edge/function.ts b/src/aws/edge/function.ts index 410e7559..5f5c4bdf 100644 --- a/src/aws/edge/function.ts +++ b/src/aws/edge/function.ts @@ -320,6 +320,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; } /** diff --git a/test/aws/edge/distribution.test.ts b/test/aws/edge/distribution.test.ts index 6ab1e6ad..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", @@ -326,7 +326,7 @@ describe("Distribution", () => { Template.fromStack(stack); }).toThrow("Only one function association is allowed per event type"); }); - test("Should throw when associating a function created with autoPublish: false", () => { + test("Should warn when associating a function created with autoPublish: false", () => { // GIVEN const bucket = new storage.Bucket(stack, "Bucket", { namePrefix: "bucket", @@ -351,12 +351,15 @@ describe("Distribution", () => { ], }, }); - // THEN + // THEN - no longer a hard failure, just an acknowledgeable warning expect(() => { Template.fromStack(stack); - }).toThrow(/autoPublish: false/); + }).not.toThrow(); + Annotations.fromStack(stack).hasWarnings({ + message: /unpublishedFunctionAssociation/, + }); }); - test("Should throw when associating an autoPublish: false function via additional behaviors", () => { + test("Should warn when associating an autoPublish: false function via additional behaviors", () => { // GIVEN const bucket = new storage.Bucket(stack, "Bucket", { namePrefix: "bucket", @@ -389,7 +392,124 @@ describe("Distribution", () => { // THEN expect(() => { Template.fromStack(stack); - }).toThrow(/autoPublish: false/); + }).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 @@ -420,6 +540,9 @@ describe("Distribution", () => { expect(() => { Template.fromStack(stack); }).not.toThrow(); + Annotations.fromStack(stack).hasNoWarnings({ + message: /unpublishedFunctionAssociation/, + }); }); test("Should not reject an imported (non-Function) IFunction association", () => { // GIVEN @@ -455,6 +578,9 @@ describe("Distribution", () => { expect(() => { Template.fromStack(stack); }).not.toThrow(); + Annotations.fromStack(stack).hasNoWarnings({ + message: /unpublishedFunctionAssociation/, + }); }); test("Should support custom Response Header Policy", () => { // GIVEN From f9a23d167fcbc4efafc8feb081c440e748fe40a9 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sun, 16 Aug 2026 19:07:25 +0700 Subject: [PATCH 5/5] fix(edge): identify Function via symbol guard instead of instanceof The unpublished-function check used `fa.function instanceof Function`, which silently skips the warning when the associated function comes from a duplicate installed copy of this library. Add the repo's cross-package runtime-identification pattern (AwsStack.isAwsStack / Role.isRole): a Symbol.for marker on Function.prototype and a static Function.isFunction type guard, and use it in Distribution.renderFunctionAssociations. --- src/aws/edge/distribution.ts | 2 +- src/aws/edge/function.ts | 21 +++++++++++++++++++++ test/aws/edge/function.test.ts | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/aws/edge/distribution.ts b/src/aws/edge/distribution.ts index 59fccdf9..c65c9ce8 100644 --- a/src/aws/edge/distribution.ts +++ b/src/aws/edge/distribution.ts @@ -546,7 +546,7 @@ export class Distribution extends AwsConstructBase implements IDistribution { // final render), so dedupe to avoid stacking identical warnings on // this node's metadata. if ( - fa.function instanceof CloudFrontFunction && + CloudFrontFunction.isFunction(fa.function) && !fa.function._autoPublish && !fa.skipPublishCheck && !this.warnedUnpublishedFunctions.has(fa.function.node.path) diff --git a/src/aws/edge/function.ts b/src/aws/edge/function.ts index 5f5c4bdf..8032010c 100644 --- a/src/aws/edge/function.ts +++ b/src/aws/edge/function.ts @@ -10,6 +10,10 @@ 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 */ @@ -186,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; @@ -349,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/edge/function.test.ts b/test/aws/edge/function.test.ts index 75a89aeb..e390bfd7 100644 --- a/test/aws/edge/function.test.ts +++ b/test/aws/edge/function.test.ts @@ -69,6 +69,24 @@ describe("Function", () => { ); }); + 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();