Specification: AWS Infrastructure for Item Image Upload
Implementation specification for the AWS Infrastructure project (Phase 1 of Item Image Upload). This document defines what to build — CDK construct interfaces, stack modifications, cross-stack exports, and the verification script — following the patterns documented in design.md section 3.
For AWS resource requirements (what the resources must do), see the AWS Specification. For project scope and success criteria, see goal.md.
1. New CDK Constructs
Section titled “1. New CDK Constructs”1.1 ImageAssetBucket
Section titled “1.1 ImageAssetBucket”File: src/main/cdk/constructs/storage/image-asset-bucket.ts
Pattern source: UploadBucket
in constructs/storage/public-upload-bucket.ts.
Purpose: Persistent S3 bucket for image assets with OAC access for CloudFront and a presigning IAM role for upload credential generation.
Interfaces
Section titled “Interfaces”import * as purpose from "arda/utils/purpose";import * as s3 from "aws-cdk-lib/aws-s3";import * as iam from "aws-cdk-lib/aws-iam";import * as ec2 from "aws-cdk-lib/aws-ec2";
export interface Configuration { readonly locator: purpose.Locator; // Browser origins allowed for CORS (presigned POST). // Empty list = VPC-only; undefined = allow all. readonly appUrls?: string[];}
export interface Props extends Configuration { // ARN of the EKS pod role that will assume the presigning role. readonly bucketClientRoleArn: string; // Logging bucket for S3 server access logs. readonly loggingBucket: s3.Bucket; // VPC endpoint for S3 — used in bucket policy conditions when // appUrls is an empty list (VPC-only mode). readonly s3VpcEndpoint: ec2.IGatewayVpcEndpoint;}
export interface Built { readonly bucket: s3.Bucket; readonly arnForObjects: string; readonly preSigningRole: iam.Role;}Key Differences from UploadBucket
Section titled “Key Differences from UploadBucket”| Aspect | UploadBucket | ImageAssetBucket |
|---|---|---|
| Versioning | false | true |
| Removal policy | DESTROY | RETAIN |
| Lifecycle expiration | 1-day TTL | None (persistent) |
| Lifecycle rules | Expire + intelligent tiering | Abort incomplete multipart after 1 day only |
| CORS methods | PUT, POST | POST only (presigned POST) |
| Bucket name | ${lcFqn}-partition-upload-bucket | ${lcFqn}-image-assets-bucket |
| Presigning role name | ${fqn}-UploadPreSigningRole | ${fqn}-ImageUploadPreSigningRole |
| OAC policy | None | s3:GetObject grant for CloudFront OAC principal |
Bucket Policy Additions
Section titled “Bucket Policy Additions”Beyond the standard presigning-role policies (inherited from the
UploadBucket pattern), add:
- OAC statement: Allow
s3:GetObjectfor the CloudFront service principal with conditionaws:SourceArnmatching the distribution ARN. This is set after the CDN construct creates the distribution — the bucket construct exposes thebucketresource for the CDN construct to callbucket.addToResourcePolicy().
Bucket Properties
Section titled “Bucket Properties”Per AWS-FR-006 (encryption, versioning), AWS-FR-007 (block public access), AWS-FR-005 (lifecycle rules):
{ bucketName: `${lcFqn}-image-assets-bucket`, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // AWS-FR-007 objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED, enforceSSL: true, encryption: s3.BucketEncryption.S3_MANAGED, // AWS-FR-006 versioned: true, // AWS-FR-006 removalPolicy: cdk.RemovalPolicy.RETAIN, // AWS-NFR-003 lifecycleRules: [{ id: "abort-incomplete-multipart", // AWS-FR-005 abortIncompleteMultipartUploadAfter: cdk.Duration.days(1), }], serverAccessLogsBucket: props.loggingBucket, serverAccessLogsPrefix: `${bucketName}-access-logs/`, cors: /* see CORS Configuration below */}CORS Configuration
Section titled “CORS Configuration”When appUrls is defined (browser-direct uploads enabled), apply CORS:
cors: [{ allowedMethods: [s3.HttpMethods.POST], allowedOrigins: props.appUrls, allowedHeaders: [ "Content-Type", "x-amz-meta-*", "x-amz-server-side-encryption", ], maxAge: 3600, // 1 hour}]This allows the SPA to submit presigned POST forms directly to S3.
The allowedHeaders list covers the metadata fields required by the
presigned POST policy conditions
(AWS-FR-003). When appUrls is an empty list, CORS is omitted (VPC-only
mode).
#### Presigning Role
Same structure as `UploadBucket.configurePresignRole()`:
- `s3:PutObject`, `s3:GetObject` with HTTPS + SSE-S3 conditions- Multipart upload actions- `s3:ListBucket` for administrative use- Assumed by `props.bucketClientRoleArn` (EKS pod role)
See [AWS-FR-003](../system-design/results/aws-specification.md) forpresigned POST policy conditions enforced at the S3 level.
****
### 1.2 ImageAssetCdn
**File**: `src/main/cdk/constructs/xgress/image-asset-cdn.ts`
**Pattern source**: [`ApiCloudFront`](https://github.com/Arda-cards/infrastructure/blob/main/src/main/cdk/constructs/xgress/api-cloudfront.ts)in `constructs/xgress/api-cloudfront.ts`.
**Purpose**: CloudFront distribution with OAC origin to the image assetbucket, signed cookie access control, and a custom domain.
#### Interfaces
```typescriptimport * as purpose from "arda/utils/purpose";import * as s3 from "aws-cdk-lib/aws-s3";import * as cloudfront from "aws-cdk-lib/aws-cloudfront";import * as r53 from "aws-cdk-lib/aws-route53";
export interface Configuration { readonly locator: purpose.Locator; // Custom domain: <partition>.<infra>.assets.arda.cards readonly customDomainName?: string; readonly tags?: { [key: string]: string };}
export interface Props extends Configuration { // The S3 bucket to serve as origin. readonly imageBucket: s3.IBucket; // ACM certificate ARN (must be in us-east-1). readonly certificateArn: string; // CloudFront key group for signed cookie validation. readonly keyGroup: cloudfront.IKeyGroup; // Route53 hosted zone for DNS record creation. readonly hostedZone?: r53.IHostedZone; // Logging bucket for CloudFront access logs (optional). readonly logBucket?: s3.IBucket;}
export interface Built { readonly distribution: cloudfront.Distribution; readonly distributionDomainName: string; // The custom domain if configured, otherwise the CloudFront domain. readonly servingDomain: string;}Key Differences from ApiCloudFront
Section titled “Key Differences from ApiCloudFront”| Aspect | ApiCloudFront | ImageAssetCdn |
|---|---|---|
| Origin | API Gateway HTTP (HttpOrigin) | S3 bucket (S3BucketV2Origin with OAC) |
| Viewer access | Open | Trusted key groups (signed cookies) |
| Cache policy | CACHING_DISABLED | CACHING_OPTIMIZED |
| Allowed methods | ALLOW_ALL | ALLOW_GET_HEAD |
| Viewer protocol | REDIRECT_TO_HTTPS | HTTPS_ONLY |
| Price class | Default | PRICE_CLASS_100 (US/Canada/Europe) |
| Custom domain | API GW domain name | <partition>.<infra>.assets.arda.cards |
| DNS record | None (handled by DNS stack) | Route53 A record (alias to distribution) created by construct |
| Default TTL | N/A (no caching) | 86400 (24 hours) |
| Max TTL | N/A (no caching) | 31536000 (1 year) |
Distribution Configuration
Section titled “Distribution Configuration”Per AWS-FR-004:
{ comment: `${fqn}-ImageCDN - Distribution for image assets`, defaultBehavior: { origin: new origins.S3BucketV2Origin(props.imageBucket, { originAccessControl: oac, // created within construct }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY, allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, trustedKeyGroups: [props.keyGroup], }, domainNames: props.customDomainName ? [props.customDomainName] : undefined, certificate: /* from props.certificateArn */, priceClass: cloudfront.PriceClass.PRICE_CLASS_100, defaultRootObject: undefined, // no default root — all paths are object keys}TTL configuration: The default TTL (86400s / 24 hours) applies when
the origin does not send Cache-Control headers. The max TTL (31536000s
/ 1 year) allows CloudFront to cache objects for up to a year when the
origin sends Cache-Control: immutable, max-age=31536000. This is safe
because image objects are effectively immutable — each upload generates a
new UUID key, so the same key always serves the same content. Replaced
images get a new key; the old key’s cache entry expires naturally without
invalidation.
OAC Setup
Section titled “OAC Setup”The construct creates a cloudfront.S3OriginAccessControl and grants
s3:GetObject on the bucket via the OAC’s service principal. This
replaces legacy OAI.
DNS Record
Section titled “DNS Record”If props.hostedZone is provided and props.customDomainName is set,
the construct creates a Route53 A record (alias) pointing to the
CloudFront distribution.
1.3 CloudFrontSigningKeyGroup
Section titled “1.3 CloudFrontSigningKeyGroup”File: src/main/cdk/constructs/xgress/cloudfront-signing-key-group.ts
Purpose: RSA key pair for CloudFront signed cookie validation. Public key registered in a CloudFront key group; private key stored in Secrets Manager for BFF retrieval at runtime.
Interfaces
Section titled “Interfaces”import * as purpose from "arda/utils/purpose";import * as cloudfront from "aws-cdk-lib/aws-cloudfront";import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
export interface Configuration { readonly locator: purpose.Locator; // Increment to trigger key rotation. A new key pair is generated // and added to the key group alongside existing keys. Default: 1. readonly keyVersion?: number;}
export interface Props extends Configuration {}
export interface Built { readonly keyGroup: cloudfront.IKeyGroup; readonly keyPairId: string; readonly privateKeySecret: secretsmanager.ISecret; readonly privateKeySecretArn: string;}Key Generation via Lambda Custom Resource
Section titled “Key Generation via Lambda Custom Resource”The construct uses a CDK custom resource (Lambda-backed) to generate an
RSA 2048-bit key pair at deploy time. This follows the existing pattern
in the repo (write-ns-records-to-upstream-dns.ts,
cognito-add-tenant.ts) where inline Lambda functions perform operations
that CDK cannot express declaratively.
Lambda behavior:
- OnCreate: Generate RSA 2048-bit key pair using Node.js
cryptomodule (generateKeyPairSync). Store private key PEM in Secrets Manager. Return public key PEM in the custom resource response. - OnUpdate: If
keyVersionchanged, generate a new key pair. Store new private key in a new Secrets Manager secret (versioned name:${fqn}-ImageCdnSigningKey-v${keyVersion}). Return new public key PEM. The old key remains in the key group until manually removed. - OnDelete: No-op (RETAIN policy). Keys are never automatically deleted.
Lambda IAM permissions (least privilege):
secretsmanager:CreateSecretonarn:aws:secretsmanager:*:*:secret:${fqn}-ImageCdnSigningKey*secretsmanager:PutSecretValueon the same pattern- No
secretsmanager:DeleteSecret— deletion is a manual OAM action
Lambda location: Inline in the construct file as a lambda_nodejs
bundled function, following the constructs/inline-lambdas/ pattern.
CloudFront Resources
Section titled “CloudFront Resources”- Public key:
cloudfront.PublicKeycreated from the PEM returned by the Lambda custom resource. - Key group:
cloudfront.KeyGroupreferencing the public key. Supports multiple items for zero-downtime rotation (AWS-NFR-004). - Secret naming:
${fqn}-ImageCdnSigningKey(or${fqn}-ImageCdnSigningKey-v${keyVersion}when rotated). - Removal policy:
RETAINon the secret, key group, and public key — deleting these would break all active signed cookies.
Key Rotation (Future OAM)
Section titled “Key Rotation (Future OAM)”The keyVersion parameter enables zero-downtime rotation:
- Increment
keyVersionin the partition configuration. cdk deploytriggers the Lambda to generate a new key pair.- The new public key is added to the key group alongside the existing one. CloudFront accepts cookies signed with either key.
- Update the BFF to sign new cookies with the new private key (the BFF reads the secret ARN from CloudFormation exports — the export is updated to point to the new secret).
- After the cookie TTL window passes (30 minutes default), all existing cookies have expired. The old key can be removed from the key group and the old secret deleted.
Steps 4-5 are manual OAM actions outside this project’s scope. The construct is designed to support them by keeping the key group open to multiple keys and using versioned secret names.
Per CDN Access Control for the cookie signing design.
2. Stack Modifications
Section titled “2. Stack Modifications”2.1 BulkStoresStack
Section titled “2.1 BulkStoresStack”File: src/main/cdk/stacks/purpose/partition-bulk-stores.ts
No changes. BulkStoresStack is unchanged from its original state.
The ImageAssetBucket is co-located with the CDN in ImageStorageStack
(PD-04) to avoid a cross-stack circular dependency
caused by CloudFront OAC auto-adding a bucket policy that references the
distribution.
2.2 ImageStorageStack (New Stack)
Section titled “2.2 ImageStorageStack (New Stack)”File: src/main/cdk/stacks/purpose/image-storage.ts
Decision PD-01: New dedicated stack rather than
extending PurposeIngress. The image CDN has a different lifecycle
(RETAIN bucket, long-lived caches) from the API ingress, and separating
it follows the existing pattern where BulkStoresStack is independent
from PurposeIngress.
Decision PD-04: The ImageAssetBucket is
co-located in this stack alongside the CDN and signing key group. CDK’s
S3BucketOrigin.withOriginAccessControl() auto-adds a bucket policy
referencing the distribution — when bucket and distribution are in
different stacks, this creates a cyclic CloudFormation reference.
Co-locating them eliminates the cycle. Bucket, CDN, and signing keys
form a cohesive unit with shared lifecycle (RETAIN).
ExportKeys (all 6 partition exports):
export type ExportKeys = | "imageAssetBucketArnAPI" | "imageAssetBucketNameAPI" | "imagePresignRoleArnAPI" | "imageCdnDomainAPI" | "imageCdnSigningKeyIdAPI" | "imageCdnSigningKeySecretArnAPI";With export names:
imageAssetBucketArnAPI: { exportName: `${publishingPrefix}-API-ImageAssetBucketArn`, description: "Image Asset Bucket ARN",},imageAssetBucketNameAPI: { exportName: `${publishingPrefix}-API-ImageAssetBucketName`, description: "Image Asset Bucket Name",},imagePresignRoleArnAPI: { exportName: `${publishingPrefix}-API-ImagePresignRoleArn`, description: "Image Upload Pre-Signing Role ARN",},imageCdnDomainAPI: { exportName: `${publishingPrefix}-API-ImageCdnDomain`, description: "Image CDN Domain Name",},imageCdnSigningKeyIdAPI: { exportName: `${publishingPrefix}-API-ImageCdnSigningKeyId`, description: "CloudFront Signing Key Pair ID",},imageCdnSigningKeySecretArnAPI: { exportName: `${publishingPrefix}-API-ImageCdnSigningKeySecretArn`, description: "Secrets Manager ARN for CloudFront Signing Private Key",},2.3 partition.ts
Section titled “2.3 partition.ts”File: src/main/cdk/apps/Al1x/partition.ts
Changes to buildPartition():
-
Import the new stack module.
-
Instantiate the image storage stack after
bulkStores(needs the logging bucket) and using infrastructure imports (hosted zone and certificate fromimportedStack):
//-------------------------------------------------------------// Build Image Storage (bucket + CDN + signing keys)//-------------------------------------------------------------const imageStorage = new imgStorage.ImageStorageStack( app, `${partitionPrefix}-ImageStorage`, { locator: partition.locator, loggingBucket: bulkStores.built.loggingBucket, bucketClientRoleArn: importedStack.eksImports.podRoleArn.value as string, s3VpcEndpoint: importedStack.networkImports.s3VpcEndpoint, certificateArn: importedStack.ingressImports.assetsCertificateArn.value ? (importedStack.ingressImports.assetsCertificateArn.value as string) : undefined, hostedZone: importedStack.assetsHostedZone, ...partialStackProps, },);imageStorage.addDependency(bulkStores);imageStorage.publish();- Order: After
bulkStores(needs logging bucket), before or parallel withpurposeIngressStack(no dependency between API ingress and image storage).
2.4 Root and Infrastructure DNS (PD-02)
Section titled “2.4 Root and Infrastructure DNS (PD-02)”The image CDN uses a dedicated assets.arda.cards domain family. This
requires changes at two levels before the partition-level CDN can use a
custom domain.
RootConfigurationStack
Section titled “RootConfigurationStack”File: src/main/cdk/stacks/root/root-configuration-stack.ts
Changes:
- Add
assets.arda.cardspublic hosted zone (same pattern as io/app/auth). - Extend
ExportKeyswithassetsZone. - Extend
publish()to export the new zone ID.
export type ExportKeys = | "appZone" | "ioZone" | "authZone" | "assetsZone" // NEW | "allowCreateNsRecordRole";InfrastructureIngress
Section titled “InfrastructureIngress”File: src/main/cdk/stacks/infrastructure/ingress-stack.ts
Changes:
- Add
<infra>.assets.arda.cardssubdomain zone (same pattern as io/app/auth subdomain zones). - Write NS delegation records to the root
assets.arda.cardszone via the existing cross-account role. - Create wildcard ACM certificate
*.<infra>.assets.arda.cardswith DNS validation against the new subdomain zone. - Extend
ExportKeyswithassetsDomainName,assetsZoneId,assetsZoneNameServers,assetsCertificateArn.
export type ExportKeys = // ... existing keys ... | "assetsDomainName" | "assetsZoneId" | "assetsZoneNameServers" | "assetsCertificateArn";ImportingStack (util.ts)
Section titled “ImportingStack (util.ts)”File: src/main/cdk/apps/Al1x/util.ts
Changes: Import the new assetsHostedZone and assetsCertificateArn
from InfrastructureIngress exports. Expose as importedStack.assetsHostedZone
and importedStack.ingressImports.assetsCertificateArn.
ari-configuration.ts
Section titled “ari-configuration.ts”File: src/main/cdk/platform/ari-configuration.ts
Add the assets domain pattern:
export const ASSETS_DOMAIN_PREFIX = "assets";export const ASSETS_DOMAIN = `${ASSETS_DOMAIN_PREFIX}.${ROOT_DOMAIN}`;
export function assetsDomain(purpose: purposeUtils.Locator): string { return `${purposeUtils.subdomain(purpose)}.${ASSETS_DOMAIN}`;}The resulting domain is demo.alpha001.assets.arda.cards.
deploy-root.sh (PD-03)
Section titled “deploy-root.sh (PD-03)”File: infrastructure/deploy-root.sh
Minimal script for root account CDK deployment:
#!/usr/bin/env bashset -eu
echo ">>>>>>>>> Root Account Deployment"
# Authenticate (SSO or profile)AWS_DEFAULT_PROFILE="${AWS_DEFAULT_PROFILE:-Admin-PlatformRoot}"export AWS_DEFAULT_PROFILE
# Bootstrapaccount_id="841876193886"region="us-east-1"npx cdk bootstrap "aws://${account_id}/${region}"
# Deploy root configuration (hosted zones + NS role)npx cdk deploy --all --require-approval never \ --app "npx ts-node -r tsconfig-paths/register \ --prefer-ts-exts src/main/cdk/apps/rootConfiguration/r53-zones.ts"
echo ">>>>>>>>> Root deployment complete"This follows the same CDK invocation pattern as amm.sh step 1.2 but
scoped to the single root CDK app. Run manually before the first
amm.sh deployment when root zone changes are needed.
Deployment Order
Section titled “Deployment Order”- Root first: Run
deploy-root.shto create theassets.arda.cardszone. This is a one-time prerequisite. - Infrastructure via amm.sh: The next
amm.shrun picks up theInfrastructureIngresschanges (subdomain zone + ACM cert) automatically. - Partition via amm.sh: Same run deploys the partition stacks
including the new
ImageStorageStack(bucket + CDN + signing keys).
3. Verification Script
Section titled “3. Verification Script”File: infrastructure/tools/verify-image-cdn.ts
Purpose: End-to-end validation of the deployed infrastructure. Executes goal.md success criteria 3–7.
CLI Interface
Section titled “CLI Interface”npx ts-node tools/verify-image-cdn.ts \ --bucket <bucket-name> \ --cdn-domain <cloudfront-domain> \ --presign-role-arn <arn> \ --signing-key-id <key-pair-id> \ --signing-key-secret-arn <secret-arn> \ --tenant-id <test-tenant-uuid>All parameters can be derived from CloudFormation exports. A future enhancement can auto-resolve them from the export names.
| Step | Action | Expected | Maps to |
|---|---|---|---|
| 1 | sts:AssumeRole on presigning role | Credentials returned | Goal SC-3 |
| 2 | Generate presigned POST form for key <tenantId>/images/test-<uuid>.jpg | Form fields + URL | Goal SC-4 |
| 3 | POST test JPEG to S3 using presigned form | HTTP 204 | Goal SC-4 |
| 4 | s3:HeadObject on uploaded key | Object exists, metadata matches | Goal SC-4 |
| 5 | GET from CloudFront without cookies | HTTP 403 | Goal SC-5 |
| 6 | Retrieve signing private key from Secrets Manager | PEM key | — |
| 7 | Generate CloudFront signed cookies (custom policy, scoped to /<tenantId>/*) | Three cookie values | — |
| 8 | GET from CloudFront with signed cookies | HTTP 200, image content | Goal SC-6 |
| 9 | Generate cookies scoped to a different tenant ID | Three cookie values | — |
| 10 | GET original image with wrong-tenant cookies | HTTP 403 | Goal SC-7 |
| 11 | Delete test object from S3 | Cleanup | — |
Dependencies
Section titled “Dependencies”Add to infrastructure/package.json devDependencies:
@aws-sdk/client-s3(if not already present)@aws-sdk/client-sts@aws-sdk/client-secrets-manager@aws-sdk/s3-presigned-post(for presigned POST generation)
4. File Change Summary
Section titled “4. File Change Summary”Root and Infrastructure (DNS Foundation)
Section titled “Root and Infrastructure (DNS Foundation)”| # | File | Action | Description |
|---|---|---|---|
| 1 | src/main/cdk/stacks/root/root-configuration-stack.ts | Modify | Add assets.arda.cards hosted zone, extend exports |
| 2 | src/main/cdk/stacks/infrastructure/ingress-stack.ts | Modify | Add <infra>.assets.arda.cards subdomain zone, NS delegation, ACM cert, extend exports |
| 3 | src/main/cdk/apps/Al1x/util.ts | Modify | Import assets hosted zone and certificate in ImportingStack |
| 4 | src/main/cdk/platform/ari-configuration.ts | Modify | Add ASSETS_DOMAIN_PREFIX, ASSETS_DOMAIN, and assetsDomain() |
| 5 | deploy-root.sh | New | Minimal root account deployment script (profile: Admin-PlatformRoot) |
| 6 | tools/ci-root-check.js | New | Root configuration synth check (separate from ci-check.js for infra/partition targets) |
Partition (Image Storage and CDN)
Section titled “Partition (Image Storage and CDN)”| # | File | Action | Description |
|---|---|---|---|
| 7 | src/main/cdk/constructs/storage/image-asset-bucket.ts | New | ImageAssetBucket construct (includes presigning role) |
| 8 | src/main/cdk/constructs/xgress/image-asset-cdn.ts | New | ImageAssetCdn construct |
| 9 | src/main/cdk/constructs/xgress/cloudfront-signing-key-group.ts | New | CloudFrontSigningKeyGroup construct (Lambda custom resource for RSA key generation) |
| 10 | src/main/cdk/constructs/inline-lambdas/generate-signing-key.ts | New | Inline Lambda for RSA key pair generation |
| 11 | src/main/cdk/stacks/purpose/image-storage.ts | New | ImageStorageStack (bucket + CDN + signing key group) |
| 12 | src/main/cdk/apps/Al1x/partition.ts | Modify | Wire new stack into buildPartition() |
Testing Infrastructure and Unit Tests
Section titled “Testing Infrastructure and Unit Tests”| # | File | Action | Description |
|---|---|---|---|
| 13 | jest.config.ts | New | Jest config with ts-jest preset and arda/* path aliases |
| 14 | package.json | Modify | Add test, test:ci scripts; add cdk-nag, @aws-sdk/s3-presigned-post devDependencies |
| 15 | src/main/cdk/constructs/storage/image-asset-bucket.test.ts | New | Unit tests for ImageAssetBucket |
| 16 | src/main/cdk/constructs/xgress/image-asset-cdn.test.ts | New | Unit tests for ImageAssetCdn |
| 17 | src/main/cdk/constructs/xgress/cloudfront-signing-key-group.test.ts | New | Unit tests for CloudFrontSigningKeyGroup |
| 18 | src/main/cdk/stacks/purpose/image-storage.test.ts | New | Snapshot + export tests for ImageStorageStack |
Verification and CI
Section titled “Verification and CI”| # | File | Action | Description |
|---|---|---|---|
| 19 | tools/verify-image-cdn.ts | New | End-to-end verification script |
| 20 | .github/workflows/ci.yaml | Modify | Add npm test step to the build job (after npm run lint). This gates every PR and push with unit tests + cdk-nag. |
5. Testing Strategy
Section titled “5. Testing Strategy”Test Infrastructure Setup
Section titled “Test Infrastructure Setup”If not already present (may be created by infrastructure#433):
jest.config.tswithts-jestpreset andmoduleNameMapperforarda/*path aliases"test"and"test:ci"scripts inpackage.jsoncdk-nagAwsSolutions pack added to CDK app entry points
CI Integration
Section titled “CI Integration”Add npm test to the existing build job in .github/workflows/ci.yaml
so that unit tests and cdk-nag gate every PR and push:
# In the build job steps, after lint:- run: npm run lint- run: npm test # NEW — runs jest (unit tests + cdk-nag)This ensures no PR can merge with failing construct tests or cdk-nag
violations. The synth-each-cdk-app matrix job continues to validate
that all targets synthesize independently.
Unit Tests for New Constructs
Section titled “Unit Tests for New Constructs”Each new construct gets co-located unit tests (<construct>.test.ts)
using aws-cdk-lib/assertions. Tests verify business logic, not CDK
defaults.
ImageAssetBucket tests:
- Bucket name follows
${lcFqn}-image-assets-bucketpattern - Versioning enabled (differs from
UploadBucket) - Removal policy is
RETAIN(differs fromUploadBucket) - SSE-S3 encryption (
AES256) BlockPublicAccess.BLOCK_ALL- No expiration lifecycle rule (only abort-incomplete-multipart)
- CORS allows POST method only (when
appUrlsdefined) - CORS absent when
appUrlsis empty list - Presigning role has HTTPS + SSE-S3 conditions on
s3:PutObject/s3:GetObject - Presigning role includes multipart upload actions
- Presigning role is assumable by
bucketClientRoleArn validateProps()rejects invalid inputs
ImageAssetCdn tests:
- CloudFront distribution uses S3 OAC origin (not HTTP origin)
- Viewer protocol policy is
HTTPS_ONLY - Allowed methods are
GET,HEADonly - Cache policy is
CachingOptimized - Price class is
PRICE_CLASS_100 - Trusted key groups configured (signed cookies required)
- Custom domain set when
customDomainNameprovided - Route53 A record created when
hostedZoneprovided validateProps()rejects missing certificate ARN
CloudFrontSigningKeyGroup tests:
- CloudFront public key resource created
- CloudFront key group references the public key
- Secrets Manager secret created for private key
- Secret removal policy is
RETAIN - Key group removal policy is
RETAIN
Snapshot Tests for Modified Stacks
Section titled “Snapshot Tests for Modified Stacks”ImageStorageStack: snapshot captures the full stack includingImageAssetBucket, CDN, and signing key group; all 6-API-Image*exports present
cdk-nag Compliance
Section titled “cdk-nag Compliance”New constructs must pass AwsSolutionsChecks without unsuppressed
findings. Expected suppressions (with documented reasons):
AwsSolutions-S1on the image asset bucket if access logging is directed to the shared logging bucket (already configured)AwsSolutions-CFR4if CloudFront logging is deferred to a future enhancement
6. Acceptance Criteria
Section titled “6. Acceptance Criteria”Acceptance criteria are defined in goal.md — Success Criteria
(SC-1 through SC-8). The verification script (verify-image-cdn.ts)
automates SC-3 through SC-7 as defined in section 3 above.
Additional implementation-level acceptance:
- All new constructs follow the
Configuration → Props → Builtpattern per design.md section 3.1. - All stacks use the
ExportKeys/ExportDefinition/publish()pattern per design.md section 3.2. - Naming follows design.md section 3.3.
cdk synthproduces valid templates for all existing infrastructure and partition targets (npm run ci-checkpasses).- No regressions to existing stacks — all current exports remain unchanged.
npm testpasses — all unit tests and snapshot tests for new constructs and modified stacks pass.- cdk-nag
AwsSolutionsCheckspasses for all stacks containing new constructs.
Related Tickets
Section titled “Related Tickets”- infrastructure#433 — Retrofit tests for pre-existing constructs (separate scope)
- infrastructure#434 — Implement cfn-guard policy-as-code (separate scope)
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved