2026-09-10 19:15:11 -04:00
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { createRequire } from "node:module";
|
|
|
|
|
import { test } from "node:test";
|
|
|
|
|
|
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
|
const { App } = require("aws-cdk-lib");
|
|
|
|
|
const { Template } = require("aws-cdk-lib/assertions");
|
|
|
|
|
const { FrontendStack } = require("../lib/frontend-stack.js");
|
|
|
|
|
|
|
|
|
|
const account = "396287094661";
|
|
|
|
|
const region = "us-east-1";
|
|
|
|
|
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
|
|
|
|
|
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
|
|
|
|
|
const CONDITION = "ManageSiteInfrastructureCondition";
|
|
|
|
|
|
|
|
|
|
const RETAINED_TYPES = new Set([
|
|
|
|
|
"AWS::S3::Bucket",
|
|
|
|
|
"AWS::S3::BucketPolicy",
|
|
|
|
|
"AWS::CloudFront::Distribution",
|
|
|
|
|
"AWS::CloudFront::Function",
|
|
|
|
|
"AWS::CloudFront::OriginAccessControl",
|
|
|
|
|
"AWS::Route53::RecordSet",
|
|
|
|
|
"Custom::S3AutoDeleteObjects",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
function devTemplate(retainForTerraformAdoption, overrides = {}) {
|
|
|
|
|
const app = new App();
|
|
|
|
|
const stack = new FrontendStack(app, "shoc-frontend-dev", {
|
|
|
|
|
envName: "dev",
|
|
|
|
|
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
|
|
|
|
deployBranch: "dev",
|
|
|
|
|
domainNames: ["dev.seahaven.com"],
|
|
|
|
|
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
|
|
|
|
|
hostedZoneId: "Z07671212N75U4YLPWZR8",
|
|
|
|
|
hostedZoneName: "dev.seahaven.com",
|
|
|
|
|
retainForTerraformAdoption,
|
|
|
|
|
env: { account, region },
|
|
|
|
|
...overrides,
|
|
|
|
|
});
|
|
|
|
|
return Template.fromStack(stack).toJSON();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entriesByType(template, type) {
|
|
|
|
|
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTransferred(logicalId, resource) {
|
|
|
|
|
const isDeployRoleResource =
|
|
|
|
|
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
|
|
|
|
|
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
|
|
|
|
|
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
|
|
|
|
|
const template = devTemplate(true);
|
|
|
|
|
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
|
|
|
|
|
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
|
|
|
|
|
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
|
|
|
|
|
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
|
|
|
|
|
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
|
|
|
|
|
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
|
|
|
|
|
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
|
|
|
|
|
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
|
|
|
|
|
isTransferred(id, resource),
|
|
|
|
|
);
|
|
|
|
|
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
|
|
|
|
|
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
|
|
|
|
|
// addresses) plus the retained custom resource.
|
|
|
|
|
assert.equal(transferred.length, 10);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("adoption mode preserves the live dev identifiers", () => {
|
|
|
|
|
const template = devTemplate(true);
|
|
|
|
|
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
|
|
|
|
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
|
|
|
|
|
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
|
|
|
|
|
assert.ok(
|
|
|
|
|
bucket.Properties.Tags.some(
|
|
|
|
|
(tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
|
|
|
|
|
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
|
|
|
|
|
assert.equal(
|
|
|
|
|
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
|
|
|
|
|
DEV_ORIGIN_ID,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
|
|
|
|
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
deployRole.Properties.PermissionsBoundary,
|
|
|
|
|
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
|
|
|
|
|
);
|
|
|
|
|
assert.ok(
|
|
|
|
|
deployRole.Properties.Tags.some(
|
|
|
|
|
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
|
|
|
|
assert.equal(
|
|
|
|
|
condition.StringEquals["token.actions.githubusercontent.com:sub"],
|
|
|
|
|
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
|
|
|
|
);
|
|
|
|
|
assert.equal(condition.StringLike, undefined);
|
|
|
|
|
|
|
|
|
|
// Legacy inline policy stays byte-compatible with the live document.
|
|
|
|
|
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
|
|
|
|
|
id.startsWith("GithubDeployRole"),
|
|
|
|
|
);
|
|
|
|
|
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
|
|
|
|
|
assert.deepEqual(sids, [
|
|
|
|
|
"AssumeCdkBootstrapRoles",
|
|
|
|
|
"DescribeStack",
|
|
|
|
|
undefined,
|
|
|
|
|
"InvalidateDistribution",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
for (const output of [
|
|
|
|
|
"TerraformWorkspaceTag",
|
|
|
|
|
"TerraformDeployBoundaryArn",
|
|
|
|
|
"TerraformImportBucket",
|
|
|
|
|
"TerraformImportBucketPolicy",
|
|
|
|
|
"TerraformImportDistribution",
|
|
|
|
|
"TerraformImportOriginAccessControl",
|
|
|
|
|
"TerraformOriginAccessControlName",
|
|
|
|
|
"TerraformOriginAccessControlDescription",
|
|
|
|
|
"TerraformDistributionOriginId",
|
|
|
|
|
"TerraformImportSpaRewriteFunction",
|
|
|
|
|
"TerraformImportAliasA",
|
|
|
|
|
"TerraformImportAliasAAAA",
|
|
|
|
|
"TerraformImportDeployRole",
|
|
|
|
|
"TerraformImportDeployRolePolicy",
|
|
|
|
|
"TerraformDeployInlinePolicyName",
|
|
|
|
|
"TerraformBucketAutoDeleteHelperRoleArn",
|
|
|
|
|
"TerraformRetainedAutoDeleteCustomResource",
|
|
|
|
|
]) {
|
|
|
|
|
assert.ok(template.Outputs[output], `missing output ${output}`);
|
|
|
|
|
}
|
|
|
|
|
assert.equal(
|
|
|
|
|
template.Outputs.TerraformImportAliasA.Value,
|
|
|
|
|
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
|
|
|
|
|
);
|
|
|
|
|
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("adoption mode retains exactly the transferred resources", () => {
|
|
|
|
|
const template = devTemplate(true);
|
|
|
|
|
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
|
|
|
if (isTransferred(logicalId, resource)) {
|
|
|
|
|
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
|
|
|
|
|
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
|
|
|
|
|
} else {
|
|
|
|
|
assert.notEqual(resource.DeletionPolicy, "Retain", logicalId);
|
|
|
|
|
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// The auto-delete provider Lambda, role, and log group stay unretained.
|
|
|
|
|
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
|
|
|
|
|
for (const [, resource] of entriesByType(template, type)) {
|
|
|
|
|
assert.notEqual(resource.DeletionPolicy, "Retain");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
|
|
|
|
|
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
|
|
|
|
|
);
|
|
|
|
|
assert.equal(providerRoles.length, 1);
|
|
|
|
|
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
|
|
|
|
|
const template = devTemplate(true);
|
|
|
|
|
const parameter = template.Parameters.ManageSiteInfrastructure;
|
|
|
|
|
assert.ok(parameter);
|
|
|
|
|
assert.equal(parameter.Type, "String");
|
|
|
|
|
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
|
|
|
|
|
assert.equal(parameter.Default, undefined);
|
|
|
|
|
assert.ok(template.Conditions[CONDITION]);
|
|
|
|
|
|
|
|
|
|
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
|
|
|
if (isTransferred(logicalId, resource)) {
|
|
|
|
|
assert.equal(resource.Condition, CONDITION, logicalId);
|
|
|
|
|
} else {
|
|
|
|
|
assert.notEqual(resource.Condition, CONDITION, logicalId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const [outputName, output] of Object.entries(template.Outputs)) {
|
|
|
|
|
assert.equal(output.Condition, CONDITION, outputName);
|
|
|
|
|
}
|
2026-09-11 12:21:00 -04:00
|
|
|
|
|
|
|
|
const [, autoDeleteHandler] = entriesByType(template, "AWS::Lambda::Function")[0];
|
|
|
|
|
assert.equal(
|
|
|
|
|
autoDeleteHandler.Properties.Description,
|
|
|
|
|
"Lambda function for auto-deleting objects in the site S3 bucket.",
|
|
|
|
|
);
|
|
|
|
|
assert.equal(typeof autoDeleteHandler.Properties.Description, "string");
|
2026-09-10 19:15:11 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
|
|
|
|
|
const template = devTemplate(false);
|
|
|
|
|
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
|
|
|
|
assert.equal(bucket.DeletionPolicy, "Delete");
|
|
|
|
|
assert.equal(bucket.UpdateReplacePolicy, "Delete");
|
|
|
|
|
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
|
|
|
|
|
assert.notEqual(customResource.DeletionPolicy, "Retain");
|
|
|
|
|
|
|
|
|
|
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
|
|
|
|
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
|
|
|
|
);
|
|
|
|
|
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
|
|
|
|
|
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
|
|
|
|
|
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
|
|
|
|
assert.equal(
|
|
|
|
|
condition.StringLike["token.actions.githubusercontent.com:sub"],
|
|
|
|
|
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
|
|
|
|
);
|
|
|
|
|
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
|
|
|
|
|
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
|
|
|
|
|
assert.equal(template.Conditions?.[CONDITION], undefined);
|
|
|
|
|
for (const resource of Object.values(template.Resources)) {
|
|
|
|
|
assert.equal(resource.Condition, undefined);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("adoption mode refuses an environment without a verified origin ID", () => {
|
|
|
|
|
assert.throws(
|
|
|
|
|
() => devTemplate(true, { envName: "staging" }),
|
|
|
|
|
/No verified Terraform adoption origin ID exists for staging/,
|
|
|
|
|
);
|
|
|
|
|
});
|