Repository: pulumi/pulumi-cdk (ts)

Issue:
Support `FindInMap` intrinsic
## Hello!
<!-- Please leave this section as-is, it's designed to help others in the community know how to interact with our GitHub issues. -->

- Vote on this issue by adding a 👍 reaction
- If you want to implement this feature, comment to let us know (we'll work with you on design, scheduling, etc.)

## Issue details

Right now pulumi-cdk doesn't support the [`FindInMap`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-findinmap.html) intrinsic.

For example, this is required by `s3.Bucket` when `autoDeleteObjects: true`.

### Affected area/feature

<!-- If you know the specific area where this feature request would go (e.g. Automation API, the Pulumi Service, the Terraform bridge, etc.), feel free to put that area here.  -->


Pull request:
Add support for Fn::FindInMap intrinsic
This adds support for the CloudFormation `Fn::FindInMap` intrinsic
function.

Docs on the `Mappings` section of a CloudFormation template can be found
[here](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html).

Docs on the `Fn::FindInMap` function can be found [here](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-findinmap.html)

closes #187

Patch:
diff --git a/integration/apigateway/index.ts b/integration/apigateway/index.ts
index 2321100..fd16f10 100644
--- a/integration/apigateway/index.ts
+++ b/integration/apigateway/index.ts
@@ -9,8 +9,7 @@ class ApiGatewayStack extends pulumicdk.Stack {
         this.node.setContext('@aws-cdk/aws-apigateway:disableCloudWatchRole', 'true');
 
         new RestApi(this, 'test-api');
-        // TODO[pulumi/pulumi-cdk#187]
-        // new SfnApi(this, 'test-sfn-api');
+        new SfnApi(this, 'test-sfn-api');
         new SpecRestApi(this, 'test-spec-api');
     }
 }
diff --git a/integration/apigateway/sfn-api.ts b/integration/apigateway/sfn-api.ts
index fd2d201..58ec218 100644
--- a/integration/apigateway/sfn-api.ts
+++ b/integration/apigateway/sfn-api.ts
@@ -1,4 +1,4 @@
-import * as cognito from 'aws-cdk-lib/aws-cognito';
+import * as iam from 'aws-cdk-lib/aws-iam';
 import { Construct } from 'constructs';
 import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
 import * as apigw from 'aws-cdk-lib/aws-apigateway';
@@ -15,8 +15,13 @@ export class SfnApi extends Construct {
             stateMachineType: sfn.StateMachineType.EXPRESS,
         });
 
+        // TODO[pulumi/pulumi-cdk#62] The auto created role has too long name
+        const role = new iam.Role(this, 'StartRole', {
+            assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
+        });
         const api = new apigw.StepFunctionsRestApi(this, 'StepFunctionsRestApi', {
             deploy: false,
+            role,
             stateMachine: stateMachine,
             headers: true,
             path: false,
diff --git a/integration/custom-resource/index.ts b/integration/custom-resource/index.ts
index 19b26f2..95ed608 100644
--- a/integration/custom-resource/index.ts
+++ b/integration/custom-resource/index.ts
@@ -26,7 +26,6 @@ class S3DeploymentStack extends pulumicdk.Stack {
             autoDeleteObjects: true,
         });
 
-
         this.bucketWebsiteUrl = this.asOutput(bucket.bucketWebsiteUrl);
 
         const deploy = new s3deploy.BucketDeployment(this, 'DeployWebsite', {
@@ -41,7 +40,7 @@ class S3DeploymentStack extends pulumicdk.Stack {
         new iam.Role(this, 'CustomResourceRole', {
             assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
             inlinePolicies: {
-                'CustomResourcePolicy': new iam.PolicyDocument({
+                CustomResourcePolicy: new iam.PolicyDocument({
                     statements: [
                         new iam.PolicyStatement({
                             actions: ['s3:GetObject'],
@@ -49,29 +48,18 @@ class S3DeploymentStack extends pulumicdk.Stack {
                         }),
                     ],
                 }),
-            }
+            },
         });
     }
 }
 
-const cfg = new pulumi.Config();
-const accountId = cfg.require('accountId');
-
 class MyApp extends pulumicdk.App {
     constructor() {
         super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => {
-            const stack = new S3DeploymentStack(scope, 's3deployment', {
-                // configure the environment to prevent the bucket from using the unsupported FindInMap intrinsic (TODO[pulumi/pulumi-cdk#187])
-                props: {
-                    env: {
-                        account: accountId,
-                        region: process.env.AWS_REGION,
-                    }
-                }
-            });
+            const stack = new S3DeploymentStack(scope, 's3deployment');
             return {
                 bucketWebsiteUrl: stack.bucketWebsiteUrl,
-                bucketObjectKeys: stack.bucketObjectKeys
+                bucketObjectKeys: stack.bucketObjectKeys,
             };
         });
     }
diff --git a/integration/misc-services/index.ts b/integration/misc-services/index.ts
index d18f4c3..5b87301 100644
--- a/integration/misc-services/index.ts
+++ b/integration/misc-services/index.ts
@@ -82,6 +82,14 @@ class MiscServicesStack extends pulumicdk.Stack {
                 }),
             ],
         });
+
+        new lambda.Function(this, 'FindInMapFunc', {
+            runtime: lambda.Runtime.NODEJS_LATEST,
+            handler: 'index.handler',
+            code: lambda.Code.fromInline('def handler(event, context): return {}'),
+            // this adds a Fn::FindInMap
+            insightsVersion: lambda.LambdaInsightsVersion.VERSION_1_0_317_0,
+        });
     }
 }
 
diff --git a/integration/replace-on-changes/index.ts b/integration/replace-on-changes/index.ts
index b3d44f8..1a1213b 100644
--- a/integration/replace-on-changes/index.ts
+++ b/integration/replace-on-changes/index.ts
@@ -9,7 +9,7 @@ class ReplaceOnChangesStack extends pulumicdk.Stack {
             default: true,
         }).id;
         const azs = aws.getAvailabilityZonesOutput({}).names;
-        new ec2.SecurityGroup(this, 'sg', {
+        new ec2.SecurityGroup(this, 'security-group', {
             description: 'Some Description',
             vpc: ec2.Vpc.fromVpcAttributes(this, 'vpc', {
                 vpcId: pulumicdk.asString(vpc),
diff --git a/integration/replace-on-changes/step2/index.ts b/integration/replace-on-changes/step2/index.ts
index 9070bb4..079b6ca 100644
--- a/integration/replace-on-changes/step2/index.ts
+++ b/integration/replace-on-changes/step2/index.ts
@@ -9,7 +9,7 @@ class ReplaceOnChangesStack extends pulumicdk.Stack {
             default: true,
         }).id;
         const azs = aws.getAvailabilityZonesOutput({}).names;
-        new ec2.SecurityGroup(this, 'sg', {
+        new ec2.SecurityGroup(this, 'security-group', {
             // description is a createOnlyProperty which means this would fail
             // if `replaceOnChanges` was not working
             description: 'Some New Description',
diff --git a/src/assembly/stack.ts b/src/assembly/stack.ts
index 9c91fc0..f3e5538 100644
--- a/src/assembly/stack.ts
+++ b/src/assembly/stack.ts
@@ -1,6 +1,6 @@
 import * as path from 'path';
 import { DestinationIdentifier, FileManifestEntry } from 'cdk-assets';
-import { CloudFormationParameter, CloudFormationResource, CloudFormationTemplate } from '../cfn';
+import { CloudFormationMapping, CloudFormationParameter, CloudFormationResource, CloudFormationTemplate } from '../cfn';
 import { ConstructTree, StackMetadata } from './types';
 import { FileAssetPackaging, FileDestination } from 'aws-cdk-lib/cloud-assembly-schema';
 
@@ -109,6 +109,12 @@ export class StackManifest {
      */
     private readonly resources: { [logicalId: string]: CloudFormationResource };
 
+    /**
+     * The Mappings from the CFN Stack
+     * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html
+     */
+    public readonly mappings?: CloudFormationMapping;
+
     /**
      *
      */
@@ -118,6 +124,7 @@ export class StackManifest {
         this.dependencies = props.dependencies;
         this.outputs = props.template.Outputs;
         this.parameters = props.template.Parameters;
+        this.mappings = props.template.Mappings;
         this.metadata = props.metadata;
         this.templatePath = props.templatePath;
         this.id = props.id;
diff --git a/src/cdk-logical-id.ts b/src/cdk-logical-id.ts
new file mode 100644
index 0000000..b65f509
--- /dev/null
+++ b/src/cdk-logical-id.ts
@@ -0,0 +1,87 @@
+// Copyright 2018-2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// NOTE:
+// Most of this code was copied from https://github.com/aws/aws-cdk/blob/ccab485b87a7090ddf0773508d7b8ee84ff654b0/packages/aws-cdk-lib/core/lib/private/uniqueid.ts
+// with the modification of removing logic related to adding the path hash to the logicalId
+import * as cdk from 'aws-cdk-lib/core';
+/**
+ * Resources with this ID are hidden from humans
+ *
+ * They do not appear in the human-readable part of the logical ID
+ */
+const HIDDEN_FROM_HUMAN_ID = 'Resource';
+
+/**
+ * Resources with this ID are complete hidden from the logical ID calculation.
+ */
+const HIDDEN_ID = 'Default';
+const MAX_HUMAN_LEN = 240; // this is the value in CDK and seems like a good default to keep
+
+/**
+ * Calculates a unique ID for a set of textual components.
+ *
+ * This is forked from the internal cdk implementation with the removal of the hash suffix.
+ * We remove the hash from the CDK logical ID calculation because Pulumi already handles
+ * adding a unique random suffix and we do not want to end up with a double hash.
+ * @see https://github.com/aws/aws-cdk/blob/ccab485b87a7090ddf0773508d7b8ee84ff654b0/packages/aws-cdk-lib/core/lib/private/uniqueid.ts?plain=1#L32
+ *
+ * @param components The path components
+ * @returns a unique alpha-numeric identifier with a maximum length of 255
+ */
+export function makeUniqueId(components: string[]) {
+    components = components.filter((x) => x !== HIDDEN_ID);
+
+    if (components.length === 0) {
+        throw new Error('Unable to calculate a unique id for an empty set of components');
+    }
+
+    const unresolvedTokens = components.filter((c) => cdk.Token.isUnresolved(c));
+    if (unresolvedTokens.length > 0) {
+        throw new Error(`ID components may not include unresolved tokens: ${unresolvedTokens.join(',')}`);
+    }
+
+    const human = removeDupes(components)
+        .filter((x) => x !== HIDDEN_FROM_HUMAN_ID)
+        .map(removeNonAlphanumeric)
+        .join('')
+        .slice(0, MAX_HUMAN_LEN);
+
+    return human;
+}
+
+/**
+ * Remove duplicate "terms" from the path list
+ *
+ * If the previous path component name ends with this component name, skip the
+ * current component.
+ */
+function removeDupes(path: string[]): string[] {
+    const ret = new Array<string>();
+
+    for (const component of path) {
+        if (ret.length === 0 || !ret[ret.length - 1].endsWith(component)) {
+            ret.push(component);
+        }
+    }
+
+    return ret;
+}
+
+/**
+ * Removes all non-alphanumeric characters in a string.
+ */
+function removeNonAlphanumeric(s: string) {
+    return s.replace(/[^A-Za-z0-9]/g, '');
+}
diff --git a/src/cfn.ts b/src/cfn.ts
index de5b727..e7eb61a 100644
--- a/src/cfn.ts
+++ b/src/cfn.ts
@@ -24,10 +24,16 @@ export interface CloudFormationResource {
     readonly DependsOn?: string | string[];
 }
 
+export type CloudFormationMapping = { [mappingLogicalName: string]: TopLevelMapping };
+export type CloudFormationMappingValue = string | string[];
+export type TopLevelMapping = { [key: string]: SecondLevelMapping };
+export type SecondLevelMapping = { [key: string]: CloudFormationMappingValue };
+
 export interface CloudFormationTemplate {
     Parameters?: { [id: string]: CloudFormationParameter };
     Resources?: { [id: string]: CloudFormationResource };
     Conditions?: { [id: string]: any };
+    Mappings?: CloudFormationMapping;
     Outputs?: { [id: string]: any };
 }
 
diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts
index 83b2fc2..fff2075 100644
--- a/src/converters/app-converter.ts
+++ b/src/converters/app-converter.ts
@@ -1,5 +1,5 @@
 import * as cdk from 'aws-cdk-lib/core';
-import * as aws from '@pulumi/aws-native'
+import * as aws from '@pulumi/aws-native';
 import * as pulumi from '@pulumi/pulumi';
 import { AssemblyManifestReader, StackManifest } from '../assembly';
 import { ConstructInfo, Graph, GraphBuilder, GraphNode } from '../graph';
@@ -18,7 +18,7 @@ import {
 } from '@pulumi/aws-native';
 import { mapToAwsResource } from '../aws-resource-mappings';
 import { attributePropertyName, mapToCfnResource } from '../cfn-resource-mappings';
-import { CloudFormationResource, getDependsOn } from '../cfn';
+import { CloudFormationMapping, CloudFormationResource, getDependsOn } from '../cfn';
 import { OutputMap, OutputRepr } from '../output-map';
 import { parseSub } from '../sub';
 import { getPartition } from '@pulumi/aws-native/getPartition';
@@ -470,6 +470,43 @@ export class StackConverter extends ArtifactConverter {
                 throw new Error(`Fn::ImportValue is not yet supported.`);
             }
 
+            case 'Fn::FindInMap': {
+                return lift(([mappingLogicalName, topLevelKey, secondLevelKey]) => {
+                    if (params.length !== 3) {
+                        throw new Error(`Fn::FindInMap requires exactly 3 parameters, got ${params.length}`);
+                    }
+                    if (!this.stack.mappings) {
+                        throw new Error(`No mappings found in stack`);
+                    }
+                    if (!(mappingLogicalName in this.stack.mappings)) {
+                        throw new Error(
+                            `Mapping ${mappingLogicalName} not found in mappings. Available mappings are ${Object.keys(
+                                this.stack.mappings,
+                            )}`,
+                        );
+                    }
+                    const topLevelMapping = this.stack.mappings[mappingLogicalName];
+                    if (!(topLevelKey in topLevelMapping)) {
+                        throw new Error(
+                            `Key ${topLevelKey} not found in mapping ${mappingLogicalName}. Available keys are ${Object.keys(
+                                topLevelMapping,
+                            )}`,
+                        );
+                    }
+                    const secondLevelMapping = topLevelMapping[topLevelKey];
+                    if (!(secondLevelKey in secondLevelMapping)) {
+                        throw new Error(
+                            `Key ${secondLevelKey} not found in mapping ${mappingLogicalName}.${topLevelKey}. Available keys are ${Object.keys(
+                                secondLevelMapping,
+                            )}`,
+                        );
+                    }
+
+                    const value = secondLevelMapping[secondLevelKey];
+                    return value;
+                }, this.processIntrinsics(params));
+            }
+
             default:
                 throw new Error(`unsupported intrinsic function ${fn} (params: ${JSON.stringify(params)})`);
         }
diff --git a/src/stack.ts b/src/stack.ts
index e7f6fbc..c6533dd 100644
--- a/src/stack.ts
+++ b/src/stack.ts
@@ -18,6 +18,7 @@ import { AppConverter, StackConverter } from './converters/app-converter';
 import { PulumiSynthesizer, PulumiSynthesizerBase } from './synthesizer';
 import { AwsCdkCli, ICloudAssemblyDirectoryProducer } from '@aws-cdk/cli-lib-alpha';
 import { CdkConstruct } from './interop';
+import { makeUniqueId } from './cdk-logical-id';
 
 export type AppOutputs = { [outputId: string]: pulumi.Output<any> };
 
@@ -277,6 +278,36 @@ export class Stack extends cdk.Stack {
         // provider or would require unintuitive options in order to produce the expected result).
         return pulumi.output(this.converter.then((converter) => converter.asOutputValue(v)));
     }
+
+    /**
+     * Returns the naming scheme used to allocate logical IDs. This overrides the default method
+     *
+     * If the "human" part of the ID exceeds 240 characters, we simply trim it so
+     * the total ID doesn't exceed CloudFormation's 255 character limit.
+     *
+     * When Pulumi auto names the resource it will add an 8 character random identifier to the end
+     *
+     * Special cases:
+     *
+     * - For aesthetic reasons, if the last components of the path are the same
+     *   (i.e. `L1/L2/Pipeline/Pipeline`), they will be de-duplicated to make the
+     *   resulting human portion of the ID more pleasing: `L1L2Pipeline`
+     *   instead of `L1L2PipelinePipeline`
+     * - If a component is named "Default" it will be omitted from the path. This
+     *   allows refactoring higher level abstractions around constructs without affecting
+     *   the IDs of already deployed resources.
+     * - If a component is named "Resource" it will be omitted from the user-visible
+     *   path. This reduces visual noise in the human readable
+     *   part of the identifier.
+     *
+     * @param cfnElement The element for which the logical ID is allocated.
+     */
+    protected allocateLogicalId(cfnElement: cdk.CfnElement): string {
+        const scopes = cfnElement.node.scopes;
+        const stackIndex = scopes.indexOf(cfnElement.stack);
+        const pathComponents = scopes.slice(stackIndex + 1).map((x) => x.node.id);
+        return makeUniqueId(pathComponents);
+    }
 }
 
 /**


Test patch:
diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go
index e143613..d6be63f 100644
--- a/integration/examples_nodejs_test.go
+++ b/integration/examples_nodejs_test.go
@@ -20,11 +20,8 @@ import (
 	"testing"
 	"time"
 
-	"github.com/aws/aws-sdk-go/aws/session"
-	"github.com/aws/aws-sdk-go/service/sts"
 	"github.com/pulumi/pulumi/pkg/v3/testing/integration"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
 func TestApiGateway(t *testing.T) {
@@ -103,7 +100,7 @@ func TestMisc(t *testing.T) {
 			Dir: filepath.Join(getCwd(t), "misc-services"),
 			ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
 				repoName := stack.Outputs["repoName"].(string)
-				assert.Containsf(t, repoName, "testrepob5dda46f", "Expected repoName to contain 'testrepob5dda46f'; got %s", repoName)
+				assert.Containsf(t, repoName, "testrepo", "Expected repoName to contain 'testrepo'; got %s", repoName)
 			},
 		})
 
@@ -116,7 +113,7 @@ func TestCloudFront(t *testing.T) {
 			Dir: filepath.Join(getCwd(t), "cloudfront"),
 			ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
 				bucketName := stack.Outputs["bucketName"].(string)
-				assert.Containsf(t, bucketName, "bucket83908e77", "Bucket name should contain 'bucket'")
+				assert.Containsf(t, bucketName, "bucket", "Bucket name should contain 'bucket'")
 			},
 		})
 
@@ -140,21 +137,9 @@ func TestErrors(t *testing.T) {
 // S3 bucket on delete and another for uploading the index.html file for a static website to the bucket.
 // The test validates that the website is deployed, displays the expected content and gets cleaned up on delete.
 func TestCustomResource(t *testing.T) {
-	sess := session.Must(session.NewSessionWithOptions(session.Options{
-		SharedConfigState: session.SharedConfigEnable,
-	}))
-	svc := sts.New(sess)
-
-	result, err := svc.GetCallerIdentity(&sts.GetCallerIdentityInput{})
-	require.NoError(t, err, "Failed to get AWS account ID")
-	accountId := *result.Account
-
 	test := getJSBaseOptions(t).
 		With(integration.ProgramTestOptions{
 			Dir: filepath.Join(getCwd(t), "custom-resource"),
-			Config: map[string]string{
-				"accountId": accountId,
-			},
 			// Workaround until TODO[pulumi/pulumi-aws-native#1816] is resolved.
 			Env: []string{"PULUMI_CDK_EXPERIMENTAL_MAX_NAME_LENGTH=56"},
 			ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
diff --git a/tests/cdk-resource.test.ts b/tests/cdk-resource.test.ts
index 2712c32..19d00cf 100644
--- a/tests/cdk-resource.test.ts
+++ b/tests/cdk-resource.test.ts
@@ -10,13 +10,12 @@ import { setMocks, testApp } from './mocks';
 import { MockResourceArgs } from '@pulumi/pulumi/runtime';
 import { Construct } from 'constructs';
 
-let resources: MockResourceArgs[] = [];
-beforeAll(() => {
-    resources = [];
-    setMocks(resources);
-});
-
 describe('CDK Construct tests', () => {
+    let resources: MockResourceArgs[] = [];
+    beforeAll(() => {
+        resources = [];
+        setMocks(resources);
+    });
     // DynamoDB table was previously mapped to the `aws` provider
     // otherwise this level of testing wouldn't be necessary.
     // We also don't need to do this type of testing for _every_ resource
@@ -135,7 +134,7 @@ describe('CDK Construct tests', () => {
                         Action: 'events:PutEvents',
                         Effect: 'Allow',
                         Principal: { AWS: 'arn:aws:iam::12345678912:root' },
-                        Resource: 'testbus9BA9ECFC_arn',
+                        Resource: 'testbus_arn',
                         Sid: 'cdk-testsid',
                     },
                 ],
@@ -197,3 +196,82 @@ describe('CDK Construct tests', () => {
         });
     });
 });
+
+describe('logicalId tests', () => {
+    let resources: MockResourceArgs[] = [];
+    beforeEach(() => {
+        resources = [];
+        setMocks(resources);
+    });
+    test('logicalId is generated without hash for resources', async () => {
+        await testApp((scope: Construct) => {
+            new dynamodb.Table(scope, 'Table', {
+                partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
+            });
+        });
+        const table = resources.find((res) => res.type === 'aws-native:dynamodb:Table');
+        expect(table).toBeDefined();
+        expect(table!.name).toEqual('Table');
+    });
+
+    test('logicalId with nested constructs', async () => {
+        await testApp((scope: Construct) => {
+            const construct = new Construct(scope, 'MyConstruct');
+            new dynamodb.Table(construct, 'Table', {
+                partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
+            });
+        });
+        const table = resources.find((res) => res.type === 'aws-native:dynamodb:Table');
+        expect(table).toBeDefined();
+        expect(table!.name).toEqual('MyConstructTable');
+    });
+
+    test('logicalId with nested constructs dedupped', async () => {
+        await testApp((scope: Construct) => {
+            const construct = new Construct(scope, 'MyConstruct');
+            const construct2 = new Construct(construct, 'MyConstruct');
+            new dynamodb.Table(construct2, 'Table', {
+                partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
+            });
+        });
+        const table = resources.find((res) => res.type === 'aws-native:dynamodb:Table');
+        expect(table).toBeDefined();
+        expect(table!.name).toEqual('MyConstructTable');
+    });
+
+    test('logicalId with Resource', async () => {
+        await testApp((scope: Construct) => {
+            const construct = new Construct(scope, 'Resource');
+            new dynamodb.Table(construct, 'Table', {
+                partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
+            });
+        });
+        const table = resources.find((res) => res.type === 'aws-native:dynamodb:Table');
+        expect(table).toBeDefined();
+        expect(table!.name).toEqual('Table');
+    });
+
+    test('logicalId with Default', async () => {
+        await testApp((scope: Construct) => {
+            const construct = new Construct(scope, 'Default');
+            new dynamodb.Table(construct, 'Table', {
+                partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
+            });
+        });
+        const table = resources.find((res) => res.type === 'aws-native:dynamodb:Table');
+        expect(table).toBeDefined();
+        expect(table!.name).toEqual('Table');
+    });
+
+    test('logicalId with non-alphanumeric', async () => {
+        await testApp((scope: Construct) => {
+            const construct = new Construct(scope, 'MyConstruct-123');
+            new dynamodb.Table(construct, 'Table', {
+                partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
+            });
+        });
+        const table = resources.find((res) => res.type === 'aws-native:dynamodb:Table');
+        expect(table).toBeDefined();
+        expect(table!.name).toEqual('MyConstruct123Table');
+    });
+});
diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts
index 50afa27..55d3c9b 100644
--- a/tests/converters/app-converter.test.ts
+++ b/tests/converters/app-converter.test.ts
@@ -193,64 +193,222 @@ describe('App Converter', () => {
     });
 
     test.each([
-        ['ref', createStackManifest({ Bucket: { Ref: 'resource1' } }), 'resource1_id'],
+        ['ref', createStackManifest({ resource2Props: { Bucket: { Ref: 'resource1' } } }), 'resource1_id', undefined],
         [
             'GetAtt',
             createStackManifest({
-                Bucket: { 'Fn::GetAtt': ['resource1', 'Arn'] },
+                resource2Props: {
+                    Bucket: { 'Fn::GetAtt': ['resource1', 'Arn'] },
+                },
             }),
             'resource1_arn',
+            undefined,
         ],
         [
             'Join-Ref',
             createStackManifest({
-                Bucket: { 'Fn::Join': ['', ['arn:', { Ref: 'resource1' }]] },
+                resource2Props: {
+                    Bucket: { 'Fn::Join': ['', ['arn:', { Ref: 'resource1' }]] },
+                },
             }),
             'arn:resource1_id',
+            undefined,
         ],
         [
             'Split-Select-Ref',
             createStackManifest({
-                Bucket: { 'Fn::Select': ['1', { 'Fn::Split': ['_', { Ref: 'resource1' }] }] },
+                resource2Props: {
+                    Bucket: { 'Fn::Select': ['1', { 'Fn::Split': ['_', { Ref: 'resource1' }] }] },
+                },
             }),
             'id',
+            undefined,
         ],
         [
             'Base64-Ref',
             createStackManifest({
-                Bucket: { 'Fn::Base64': { Ref: 'resource1' } },
+                resource2Props: {
+                    Bucket: { 'Fn::Base64': { Ref: 'resource1' } },
+                },
             }),
             Buffer.from('resource1_id').toString('base64'),
+            undefined,
         ],
         [
             'GetAZs-Select-Ref',
             createStackManifest({
-                Bucket: { 'Fn::Select': ['1', { 'Fn::GetAZs': 'us-east-1' }] },
+                resource2Props: {
+                    Bucket: { 'Fn::Select': ['1', { 'Fn::GetAZs': 'us-east-1' }] },
+                },
             }),
             'us-east-1b',
+            undefined,
         ],
         [
             'Sub-Ref',
             createStackManifest({
-                Bucket: { 'Fn::Sub': 'www.${resource1}-${AWS::Region}-${AWS::AccountId}' },
+                resource2Props: {
+                    Bucket: { 'Fn::Sub': 'www.${resource1}-${AWS::Region}-${AWS::AccountId}' },
+                },
             }),
             'www.resource1_id-us-east-2-12345678910',
+            undefined,
+        ],
+        [
+            'FindInMap',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] },
+                },
+                mappings: {
+                    Map: {
+                        Key: {
+                            Value: 'result',
+                        },
+                    },
+                },
+            }),
+            'result',
+            undefined,
+        ],
+        [
+            'FindInMap-Ref',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: { 'Fn::FindInMap': ['Map', { Ref: 'AWS::Region' }, 'Value'] },
+                },
+                mappings: {
+                    Map: {
+                        ['us-east-2']: {
+                            Value: 'result',
+                        },
+                    },
+                },
+            }),
+            'result',
+            undefined,
+        ],
+        [
+            'Split-FindInMap-Ref',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: {
+                        'Fn::Select': [
+                            '1',
+                            {
+                                'Fn::Split': [
+                                    ',',
+                                    {
+                                        'Fn::FindInMap': ['Map', { Ref: 'AWS::Region' }, 'Value'],
+                                    },
+                                ],
+                            },
+                        ],
+                    },
+                },
+                mappings: {
+                    Map: {
+                        ['us-east-2']: {
+                            Value: 'result1,result2',
+                        },
+                    },
+                },
+            }),
+            'result2',
+            undefined,
+        ],
+        [
+            'FindInMap-id-error',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] },
+                },
+                mappings: {
+                    OtherMap: {
+                        Key: {
+                            Value: 'result',
+                        },
+                    },
+                },
+            }),
+            'result',
+            'Mapping Map not found in mappings. Available mappings are OtherMap',
+        ],
+        [
+            'FindInMap-mappings-error',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'Value'] },
+                },
+            }),
+            'result',
+            'No mappings found in stack',
+        ],
+        [
+            'FindInMap-mappings-input-error',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: { 'Fn::FindInMap': ['Map', 'Value'] },
+                },
+            }),
+            'result',
+            'Fn::FindInMap requires exactly 3 parameters, got 2',
+        ],
+        [
+            'FindInMap-topLevel-error',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: { 'Fn::FindInMap': ['Map', 'OtherKey', 'Value'] },
+                },
+                mappings: {
+                    Map: {
+                        Key: {
+                            Value: 'result',
+                        },
+                    },
+                },
+            }),
+            'result',
+            'Key OtherKey not found in mapping Map. Available keys are Key',
+        ],
+        [
+            'FindInMap-secondLevel-error',
+            createStackManifest({
+                resource2Props: {
+                    Bucket: { 'Fn::FindInMap': ['Map', 'Key', 'OtherValue'] },
+                },
+                mappings: {
+                    Map: {
+                        Key: {
+                            Value: 'result',
+                        },
+                    },
+                },
+            }),
+            'result',
+            'Key OtherValue not found in mapping Map.Key. Available keys are Value',
         ],
     ])(
         'intrinsics %s',
-        async (_name, stackManifest, expected) => {
+        async (_name, stackManifest, expected, expectedError) => {
             const mockStackComponent = new MockAppComponent('/tmp/foo/bar/does/not/exist');
             const converter = new StackConverter(mockStackComponent, stackManifest);
-            converter.convert(new Set());
-            const promises = Array.from(converter.resources.values()).flatMap((res) => promiseOf(res.resource.urn));
-            await Promise.all(promises);
-            const bucket = converter.resources.get('resource1');
-            expect(bucket).toBeDefined();
-            const policy = converter.resources.get('resource2');
-            expect(policy).toBeDefined();
-            const policyResource = policy!.resource as BucketPolicy;
-            const policyBucket = await promiseOf(policyResource.bucket);
-            expect(policyBucket).toEqual(expected);
+            if (expectedError) {
+                expect(() => {
+                    converter.convert(new Set());
+                }).toThrow(expectedError);
+            } else {
+                converter.convert(new Set());
+                const promises = Array.from(converter.resources.values()).flatMap((res) => promiseOf(res.resource.urn));
+                await Promise.all(promises);
+                const bucket = converter.resources.get('resource1');
+                expect(bucket).toBeDefined();
+                const policy = converter.resources.get('resource2');
+                expect(policy).toBeDefined();
+                const policyResource = policy!.resource as BucketPolicy;
+                const policyBucket = await promiseOf(policyResource.bucket);
+                expect(policyBucket).toEqual(expected);
+            }
         },
         10_000,
     );
@@ -448,15 +606,15 @@ describe('Stack Converter', () => {
         const stackManifestPath = path.join(__dirname, '../test-data/custom-resource-stack/stack-manifest.json');
         const props: StackManifestProps = JSON.parse(fs.readFileSync(stackManifestPath, 'utf-8'));
         const manifest = new StackManifest(props);
-        const app = new MockAppComponent('/tmp/foo/bar/does/not/exist')
-        const stagingBucket = "my-bucket";
-        const customResourcePrefix = "my-prefix";
+        const app = new MockAppComponent('/tmp/foo/bar/does/not/exist');
+        const stagingBucket = 'my-bucket';
+        const customResourcePrefix = 'my-prefix';
         app.stacks[manifest.id] = {
             synthesizer: new MockSynth(stagingBucket, customResourcePrefix),
             node: {
                 id: 'my-stack',
-            }
-        } as unknown as CdkStack
+            },
+        } as unknown as CdkStack;
 
         const converter = new StackConverter(app, manifest);
         converter.convert(new Set());
@@ -464,7 +622,7 @@ describe('Stack Converter', () => {
         const customResource = converter.resources.get('DeployWebsiteCustomResourceD116527B');
         expect(customResource).toBeDefined();
 
-        const customResourceEmulator = customResource?.resource! as native.cloudformation.CustomResourceEmulator;
+        const customResourceEmulator = customResource!.resource! as native.cloudformation.CustomResourceEmulator;
         expect(customResourceEmulator.bucket).toBeDefined();
         expect(customResourceEmulator.data).toBeDefined();
         expect(customResourceEmulator.serviceToken).toBeDefined();
diff --git a/tests/graph.test.ts b/tests/graph.test.ts
index da7e253..9703f6c 100644
--- a/tests/graph.test.ts
+++ b/tests/graph.test.ts
@@ -201,29 +201,37 @@ describe('GraphBuilder', () => {
     });
 
     test.each([
-        ['dependsOn', createStackManifest({}, {}, ['resource1'])],
+        ['dependsOn', createStackManifest({ resource2Props: {}, resource2DependsOn: ['resource1'] })],
         [
             'ref',
             createStackManifest({
-                SomeProp: { Ref: 'resource1' },
+                resource2Props: {
+                    SomeProp: { Ref: 'resource1' },
+                },
             }),
         ],
         [
             'GetAtt',
             createStackManifest({
-                SomeProp: { 'Fn::GetAtt': ['resource1', 'Arn'] },
+                resource2Props: {
+                    SomeProp: { 'Fn::GetAtt': ['resource1', 'Arn'] },
+                },
             }),
         ],
         [
             'Sub-Ref',
             createStackManifest({
-                SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { Ref: 'resource1' } }] },
+                resource2Props: {
+                    SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { Ref: 'resource1' } }] },
+                },
             }),
         ],
         [
             'Sub-GetAtt',
             createStackManifest({
-                SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { 'Fn::GetAtt': ['resource1', 'Arn'] } }] },
+                resource2Props: {
+                    SomeProp: { 'Fn::Sub': ['www.${Domain}', { Domain: { 'Fn::GetAtt': ['resource1', 'Arn'] } }] },
+                },
             }),
         ],
     ])('adds edge for %s', (_name, stackManifest) => {
@@ -486,11 +494,17 @@ test('parses custom resources', () => {
     expect(deployWebsiteCR).toBeDefined();
     expect(deployWebsiteCR?.construct.type).toEqual('aws-cdk-lib:CfnResource');
     expect(deployWebsiteCR?.resource).toBeDefined();
-    const deployWebsiteCRResource = deployWebsiteCR?.resource!;
+    const deployWebsiteCRResource = deployWebsiteCR!.resource!;
     expect(deployWebsiteCRResource.Type).toEqual('Custom::CDKBucketDeployment');
-    expect(deployWebsiteCRResource.Properties.ServiceToken).toEqual({ 'Fn::GetAtt': ['CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536', 'Arn'] });
-    expect(deployWebsiteCRResource.Properties.SourceBucketNames).toEqual(['pulumi-cdk-stom-res-d817419f-staging-616138583583-us-west-2']);
-    expect(deployWebsiteCRResource.Properties.SourceObjectKeys).toEqual(['a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd.zip']);
+    expect(deployWebsiteCRResource.Properties.ServiceToken).toEqual({
+        'Fn::GetAtt': ['CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536', 'Arn'],
+    });
+    expect(deployWebsiteCRResource.Properties.SourceBucketNames).toEqual([
+        'pulumi-cdk-stom-res-d817419f-staging-616138583583-us-west-2',
+    ]);
+    expect(deployWebsiteCRResource.Properties.SourceObjectKeys).toEqual([
+        'a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd.zip',
+    ]);
 
     const testRole = graph.nodes.find((node) => node.logicalId === 'CustomResourceRoleAB1EF463');
     expect(testRole).toBeDefined();
@@ -500,7 +514,9 @@ test('parses custom resources', () => {
     const statement = policies[0].PolicyDocument?.Statement;
     expect(statement).toBeDefined();
     expect(statement).toHaveLength(1);
-    expect(statement[0].Resource).toEqual({ 'Fn::Join': ['', [{ 'Fn::GetAtt': ['DeployWebsiteCustomResourceD116527B', 'DestinationBucketArn'] }, '/*']] });
+    expect(statement[0].Resource).toEqual({
+        'Fn::Join': ['', [{ 'Fn::GetAtt': ['DeployWebsiteCustomResourceD116527B', 'DestinationBucketArn'] }, '/*']],
+    });
 });
 
 function edgesToArray(edges: Set<GraphNode>): string[] {
diff --git a/tests/utils.ts b/tests/utils.ts
index 7168081..51577e1 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -1,11 +1,15 @@
 import { StackManifest } from '../src/assembly';
+import { CloudFormationMapping } from '../src/cfn';
 
-export function createStackManifest(
-    resource2Props: any,
-    resource1Props?: any,
-    resource2DependsOn?: string | string[],
-    resource1DependsOn?: string | string[],
-): StackManifest {
+export interface CreateStackManifestProps {
+    resource1Props?: any;
+    resource2Props: any;
+    resource1DependsOn?: string | string[];
+    resource2DependsOn?: string | string[];
+    mappings?: CloudFormationMapping;
+}
+
+export function createStackManifest(props: CreateStackManifestProps): StackManifest {
     return new StackManifest({
         id: 'stack',
         templatePath: 'template',
@@ -34,19 +38,20 @@ export function createStackManifest(
             },
         },
         template: {
+            Mappings: props.mappings,
             Resources: {
                 resource1: {
                     Type: 'AWS::S3::Bucket',
-                    Properties: resource1Props ?? {},
-                    DependsOn: resource1DependsOn,
+                    Properties: props.resource1Props ?? {},
+                    DependsOn: props.resource1DependsOn,
                 },
                 resource2: {
                     Type: 'AWS::S3::BucketPolicy',
                     Properties: {
                         policyDocument: {},
-                        ...resource2Props,
+                        ...props.resource2Props,
                     },
-                    DependsOn: resource2DependsOn,
+                    DependsOn: props.resource2DependsOn,
                 },
             },
         },