import { describe, test, expect } from "vitest"; import { writeFileSync, unlinkSync } from "node:fs"; import { awsApply, awsDelete, rollbackStack, cfnUrl, cfnForm, capabilityParams, xmlField, stackStatus, stackId, isStackMissing, isNoUpdates, isSuccessStatus, isFailureStatus, isTerminalStatus, type AwsHttp, } from "./aws-apply"; const CREATE_OK = "arn:stack/s"; const UPDATE_OK = "arn:stack/s"; const MISSING = "ValidationErrorStack with id s does not exist"; const NO_UPDATES = "No updates are to be performed."; const describe_ = (status: string) => `arn:stack/s${status}`; describe("CFN pure helpers (#awsApply)", () => { test("cfnUrl: endpoint override vs real regional host", () => { expect(cfnUrl("http://localhost:4566", undefined, {})).toBe("http://localhost:4566/"); expect(cfnUrl(undefined, "eu-west-1", {})).toBe("https://cloudformation.eu-west-1.amazonaws.com/"); }); test("cfnUrl: AWS_ENDPOINT_URL[_CLOUDFORMATION] is an override too, the same rule as the read client (#1694)", () => { expect(cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://localhost:4566" })).toBe("http://localhost:4566/"); expect( cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1", AWS_ENDPOINT_URL_CLOUDFORMATION: "http://cfn:2" }), ).toBe("http://cfn:2/"); expect(cfnUrl("http://opt:3", "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1" })).toBe("http://opt:3/"); }); test("cfnForm stamps Action + Version", () => { expect(cfnForm("CreateStack", { StackName: "s" })).toEqual({ Action: "CreateStack", Version: "2010-05-15", StackName: "s" }); }); test("capabilityParams → Capabilities.member.N", () => { expect(capabilityParams(["CAPABILITY_NAMED_IAM", "CAPABILITY_IAM"])).toEqual({ "Capabilities.member.1": "CAPABILITY_NAMED_IAM", "Capabilities.member.2": "CAPABILITY_IAM", }); }); test("xml parsing + status classification", () => { expect(xmlField(describe_("CREATE_COMPLETE"), "StackStatus")).toBe("CREATE_COMPLETE"); expect(stackStatus(describe_("UPDATE_COMPLETE"))).toBe("UPDATE_COMPLETE"); expect(stackId(CREATE_OK)).toBe("arn:stack/s"); expect(isStackMissing(MISSING)).toBe(true); expect(isNoUpdates(NO_UPDATES)).toBe(true); expect(isSuccessStatus("CREATE_COMPLETE")).toBe(true); expect(isSuccessStatus("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS")).toBe(false); // transient, not settled expect(isFailureStatus("ROLLBACK_COMPLETE")).toBe(true); expect(isFailureStatus("CREATE_FAILED")).toBe(true); expect(isTerminalStatus("CREATE_IN_PROGRESS")).toBe(false); expect(isTerminalStatus("DELETE_COMPLETE")).toBe(true); }); }); function tmpl(extra: Record = {}): string { const p = `/tmp/chant-cfn-${process.pid}-${Math.round(performance.now())}.json`; writeFileSync(p, JSON.stringify({ ...extra, Resources: { B: { Type: "AWS::S3::Bucket" } } })); return p; } describe("awsApply flow (#awsApply)", () => { test("create path: absent stack → CreateStack → poll to CREATE_COMPLETE", async () => { const calls: string[] = []; let described = 0; const http: AwsHttp = async (_url, form) => { calls.push(form.Action); if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") }; return { status: 200, text: CREATE_OK }; }; const p = tmpl(); const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http); unlinkSync(p); expect(res).toEqual({ stackName: "s", status: "CREATE_COMPLETE", action: "created" }); expect(calls).toEqual(["DescribeStacks", "CreateStack", "DescribeStacks"]); }); test("capabilities follow the template: NAMED_IAM alone, plus AUTO_EXPAND for a Transform (#980)", async () => { const sent: Record[] = []; let described = 0; const http: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return described++ % 2 === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") }; sent.push(form); return { status: 200, text: CREATE_OK }; }; const plain = tmpl(); await awsApply({ templatePath: plain, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http); unlinkSync(plain); expect(sent[0]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM"); expect(sent[0]["Capabilities.member.2"]).toBeUndefined(); const macro = tmpl({ Transform: "AWS::SecretsManager-2020-07-23" }); await awsApply({ templatePath: macro, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http); unlinkSync(macro); expect(sent[1]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM"); expect(sent[1]["Capabilities.member.2"]).toBe("CAPABILITY_AUTO_EXPAND"); // An explicit list still wins. const explicit = tmpl({ Transform: "AWS::Serverless-2016-10-31" }); await awsApply({ templatePath: explicit, stackName: "s", endpoint: "http://x", intervalMs: 1, capabilities: ["CAPABILITY_IAM"] }, undefined, http); unlinkSync(explicit); expect(sent[2]["Capabilities.member.1"]).toBe("CAPABILITY_IAM"); expect(sent[2]["Capabilities.member.2"]).toBeUndefined(); }); test("the template's ownership Metadata becomes the STACK's own tags on create AND update (#1222)", async () => { const sent: Array> = []; let described = 0; const createHttp: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") }; sent.push(form); return { status: 200, text: CREATE_OK }; }; const p = tmpl({ Metadata: { "chant:ownership": { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "dev" } }, }); await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, createHttp); expect(sent[0].Action).toBe("CreateStack"); expect(sent[0]["Tags.member.1.Key"]).toBe("chant:env"); expect(sent[0]["Tags.member.1.Value"]).toBe("dev"); expect(sent[0]["Tags.member.2.Key"]).toBe("chant:managed-by"); expect(sent[0]["Tags.member.2.Value"]).toBe("chant"); expect(sent[0]["Tags.member.3.Key"]).toBe("chant:stack"); expect(sent[0]["Tags.member.3.Value"]).toBe("shop"); // Update path re-stamps the same tags. let updDescribed = 0; const updateHttp: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return { status: 200, text: describe_(updDescribed++ === 0 ? "CREATE_COMPLETE" : "UPDATE_COMPLETE") }; sent.push(form); return { status: 200, text: UPDATE_OK }; }; await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, updateHttp); unlinkSync(p); expect(sent[1].Action).toBe("UpdateStack"); expect(sent[1]["Tags.member.2.Key"]).toBe("chant:managed-by"); }); test("a template without the ownership Metadata sends no Tags parameter at all", async () => { const sent: Array> = []; let described = 0; const http: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") }; sent.push(form); return { status: 200, text: CREATE_OK }; }; const p = tmpl(); await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http); unlinkSync(p); expect(Object.keys(sent[0]).some((k) => k.startsWith("Tags."))).toBe(false); }); test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => { let described = 0; const http: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return { status: 200, text: describe_(described++ === 0 ? "CREATE_COMPLETE" : "UPDATE_COMPLETE") }; return { status: 200, text: UPDATE_OK }; }; const p = tmpl(); const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http); unlinkSync(p); expect(res.action).toBe("updated"); expect(res.status).toBe("UPDATE_COMPLETE"); }); test("no-op update (real-AWS error) → unchanged, no poll", async () => { const http: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return { status: 200, text: describe_("CREATE_COMPLETE") }; return { status: 400, text: NO_UPDATES }; // UpdateStack }; const p = tmpl(); const res = await awsApply({ templatePath: p, stackName: "s", endpoint: "http://x" }, undefined, http); unlinkSync(p); expect(res).toEqual({ stackName: "s", status: "UPDATE_COMPLETE", action: "unchanged" }); }); test("throws when the stack settles to a failure state", async () => { let described = 0; const http: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("ROLLBACK_COMPLETE") }; return { status: 200, text: CREATE_OK }; }; const p = tmpl(); await expect( awsApply({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http), ).rejects.toThrow(/created → ROLLBACK_COMPLETE/); unlinkSync(p); }); test("surfaces a CreateStack API error", async () => { const http: AwsHttp = async (_url, form) => form.Action === "DescribeStacks" ? { status: 400, text: MISSING } : { status: 400, text: "bad template" }; const p = tmpl(); await expect( awsApply({ templatePath: p, stackName: "s", endpoint: "http://x" }, undefined, http), ).rejects.toThrow(/CreateStack failed \(400\): bad template/); unlinkSync(p); }); }); describe("awsDelete (#awsApply)", () => { test("DeleteStack then polls until the stack is gone", async () => { const calls: string[] = []; let described = 0; const http: AwsHttp = async (_url, form) => { calls.push(form.Action); if (form.Action === "DescribeStacks") return described++ === 0 ? { status: 200, text: describe_("DELETE_IN_PROGRESS") } : { status: 400, text: MISSING }; return { status: 200, text: "" }; }; const p = tmpl(); const res = await awsDelete({ templatePath: p, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http); unlinkSync(p); expect(res).toEqual({ stackName: "s", deleted: true }); expect(calls[0]).toBe("DeleteStack"); }); }); describe("rollbackStack (#1449)", () => { test("RollbackStack then polls to UPDATE_ROLLBACK_COMPLETE", async () => { const calls: string[] = []; const http: AwsHttp = async (_url, form) => { calls.push(form.Action); if (form.Action === "DescribeStacks") return { status: 200, text: describe_("UPDATE_ROLLBACK_COMPLETE") }; return { status: 200, text: "" }; }; const res = await rollbackStack({ stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http); expect(res).toEqual({ stackName: "s", rolledBack: true, status: "UPDATE_ROLLBACK_COMPLETE" }); expect(calls[0]).toBe("RollbackStack"); expect(calls[0]).not.toBe("DescribeStacks"); // no probe first — the action itself answers }); test("posts the stack name on the Query API form", async () => { const forms: Record[] = []; const http: AwsHttp = async (_url, form) => { forms.push(form); if (form.Action === "DescribeStacks") return { status: 200, text: describe_("ROLLBACK_COMPLETE") }; return { status: 200, text: "" }; }; await rollbackStack({ stackName: "prod", endpoint: "http://x", intervalMs: 1 }, undefined, http); expect(forms[0]).toMatchObject({ Action: "RollbackStack", StackName: "prod" }); expect(forms[0].Version).toBeDefined(); }); test("an absent stack is nothing to roll back — rolledBack: false, no throw", async () => { const http: AwsHttp = async () => ({ status: 400, text: MISSING }); const res = await rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http); expect(res).toEqual({ stackName: "s", rolledBack: false }); }); test("a target without RollbackStack (Floci's UnknownAction, #947) degrades, not crashes", async () => { const http: AwsHttp = async () => ({ status: 400, text: "UnknownActionAction RollbackStack is not supported.", }); const res = await rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http); expect(res).toEqual({ stackName: "s", rolledBack: false }); }); test("any other API error throws — a compensation must not fail silently", async () => { const http: AwsHttp = async () => ({ status: 400, text: "Rollback requires a stack in UPDATE_FAILED state", }); await expect(rollbackStack({ stackName: "s", endpoint: "http://x" }, undefined, http)).rejects.toThrow( /RollbackStack failed \(400\): Rollback requires/, ); }); test("throws when the stack settles anywhere but *ROLLBACK_COMPLETE", async () => { const http: AwsHttp = async (_url, form) => { if (form.Action === "DescribeStacks") return { status: 200, text: describe_("UPDATE_ROLLBACK_FAILED") }; return { status: 200, text: "" }; }; await expect(rollbackStack({ stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http)).rejects.toThrow( /rollback → UPDATE_ROLLBACK_FAILED/, ); }); });