/**
* aws stack-level env teardown (#1222) — unit tests over fake Query-API
* transports, in the style of aws-apply.test.ts: no network, the fakes answer
* DescribeStacks/DeleteStack by parsing the form body.
*/
import { describe, test, expect } from "vitest";
import { teardownOwned, executeTeardown, resolveTeardownStacks, STACK_TYPE } from "./teardown";
import type { AwsReadHttp } from "./api/read-client";
import type { AwsHttp } from "./op/activities/aws-apply";
import type { OwnershipMarker } from "@intentius/chant/ownership";
const MARKER: OwnershipMarker = { stack: "shop", env: "dev" };
const MISSING =
"ValidationErrorStack with id x does not exist";
/** A DescribeStacks response for one stack carrying the given tags. */
function stackXml(name: string, tags: Record, status = "CREATE_COMPLETE"): string {
const members = Object.entries(tags)
.map(([k, v]) => `${k}${v}`)
.join("");
return (
`` +
`arn:aws:cloudformation:us-east-1:0:stack/${name}/uuid` +
`${name}${status}` +
`${members}` +
``
);
}
const OWNED_TAGS = { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "dev" };
const FOREIGN_ENV_TAGS = { "chant:managed-by": "chant", "chant:stack": "shop", "chant:env": "prod" };
/**
* A fake read transport: answers DescribeStacks per stack name from `stacks`;
* names not present answer "does not exist".
*/
function fakeRead(stacks: Record): AwsReadHttp {
return async (_url, init) => {
const form = new URLSearchParams(init.body);
expect(form.get("Action")).toBe("DescribeStacks");
const name = form.get("StackName") ?? "";
const xml = stacks[name];
return xml ? { status: 200, text: xml } : { status: 400, text: MISSING };
};
}
describe("resolveTeardownStacks — stacks[] else the env-named default (#932)", () => {
test("declared stacks win", () => {
expect(
resolveTeardownStacks({
environment: "dev",
marker: MARKER,
stacks: [{ name: "net" }, { name: "app", region: "eu-west-1" }],
}),
).toEqual([{ name: "net" }, { name: "app", region: "eu-west-1" }]);
});
test("explicit stack option, else the stack named after the environment", () => {
expect(resolveTeardownStacks({ environment: "dev", marker: MARKER, stack: "s1" })).toEqual([{ name: "s1" }]);
expect(resolveTeardownStacks({ environment: "dev", marker: MARKER })).toEqual([{ name: "dev" }]);
});
});
describe("teardownOwned — marker-verified stack enumeration", () => {
test("a stack whose own tags carry the requested identity is a candidate", async () => {
const result = await teardownOwned(
{ environment: "dev", marker: MARKER },
{ read: { http: fakeRead({ dev: stackXml("dev", OWNED_TAGS) }) } },
);
expect(result.candidates).toEqual([
{
name: "dev",
type: STACK_TYPE,
physicalId: "arn:aws:cloudformation:us-east-1:0:stack/dev/uuid",
marker: { stack: "shop", env: "dev" },
},
]);
expect(result.holes).toBeUndefined();
});
test("an untagged stack is a loud unverified-ownership hole, never a candidate", async () => {
const result = await teardownOwned(
{ environment: "dev", marker: MARKER },
{ read: { http: fakeRead({ dev: stackXml("dev", {}) }) } },
);
expect(result.candidates).toEqual([]);
expect(result.holes).toHaveLength(1);
expect(result.holes![0]).toMatchObject({ name: "dev", type: STACK_TYPE, reason: "filtered" });
expect(result.holes![0].detail).toMatch(/unverified-ownership/);
});
test("a stack verifiably carrying ANOTHER identity is out of scope — no candidate, no hole", async () => {
const result = await teardownOwned(
{ environment: "dev", marker: MARKER },
{ read: { http: fakeRead({ dev: stackXml("dev", FOREIGN_ENV_TAGS) }) } },
);
expect(result.candidates).toEqual([]);
expect(result.holes).toBeUndefined();
});
test("an absent stack is knowledge: nothing to tear down, no hole", async () => {
const result = await teardownOwned(
{ environment: "dev", marker: MARKER },
{ read: { http: fakeRead({}) } },
);
expect(result.candidates).toEqual([]);
expect(result.holes).toBeUndefined();
});
test("a failed DescribeStacks is a hole (#1089), never absence", async () => {
const http: AwsReadHttp = async () => ({ status: 500, text: "boom" });
const result = await teardownOwned({ environment: "dev", marker: MARKER }, { read: { http } });
expect(result.candidates).toEqual([]);
expect(result.holes).toHaveLength(1);
expect(result.holes![0]).toMatchObject({ name: "dev", type: STACK_TYPE, reason: "read-failed" });
});
test("multi-stack: every declared stack is checked, only verified ones are candidates", async () => {
const result = await teardownOwned(
{
environment: "dev",
marker: MARKER,
stacks: [{ name: "net" }, { name: "app" }, { name: "gone" }],
},
{
read: {
http: fakeRead({
net: stackXml("net", OWNED_TAGS),
app: stackXml("app", FOREIGN_ENV_TAGS),
}),
},
},
);
expect(result.candidates.map((c) => c.name)).toEqual(["net"]);
expect(result.holes).toBeUndefined();
});
});
describe("executeTeardown — re-verify, then DeleteStack to DELETE_COMPLETE", () => {
const CANDIDATE = { name: "dev", type: STACK_TYPE, marker: { stack: "shop", env: "dev" } };
/** A stateful fake pair: reads see the stack until DeleteStack lands. */
function fakeTarget(initialTags: Record | undefined) {
const deletes: string[] = [];
let present = initialTags !== undefined;
const read: AwsReadHttp = async (_url, init) => {
const form = new URLSearchParams(init.body);
const name = form.get("StackName") ?? "";
if (!present) return { status: 400, text: MISSING };
return { status: 200, text: stackXml(name, initialTags ?? {}) };
};
const apply: AwsHttp = async (_url, form) => {
if (form.Action === "DeleteStack") {
deletes.push(form.StackName ?? "");
present = false;
return { status: 200, text: "" };
}
if (form.Action === "DescribeStacks") {
return present
? { status: 200, text: stackXml(form.StackName ?? "", initialTags ?? {}) }
: { status: 400, text: MISSING };
}
throw new Error(`unexpected action ${form.Action}`);
};
return { read, apply, deletes };
}
test("a marker-verified stack is deleted and polled gone", async () => {
const target = fakeTarget(OWNED_TAGS);
const result = await executeTeardown(
{ environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
{ read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
);
expect(result.outcomes).toEqual([{ name: "dev", type: STACK_TYPE, outcome: "deleted" }]);
expect(target.deletes).toEqual(["dev"]);
});
test("an identity that no longer matches is not-prunable — DeleteStack is never sent", async () => {
const target = fakeTarget(FOREIGN_ENV_TAGS);
const result = await executeTeardown(
{ environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
{ read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
);
expect(result.outcomes[0]).toMatchObject({ name: "dev", outcome: "not-prunable" });
expect(result.outcomes[0].detail).toMatch(/unverified-ownership/);
expect(target.deletes).toEqual([]);
});
test("an untagged stack at execution time is not-prunable too", async () => {
const target = fakeTarget({});
const result = await executeTeardown(
{ environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
{ read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
);
expect(result.outcomes[0]).toMatchObject({ name: "dev", outcome: "not-prunable" });
expect(target.deletes).toEqual([]);
});
test("an already-absent stack is deleted (teardown is idempotent)", async () => {
const target = fakeTarget(undefined);
const result = await executeTeardown(
{ environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
{ read: { http: target.read }, applyHttp: target.apply, timeoutMs: 1000, intervalMs: 1 },
);
expect(result.outcomes).toEqual([
{ name: "dev", type: STACK_TYPE, outcome: "deleted", detail: "already absent" },
]);
expect(target.deletes).toEqual([]);
});
test("a failed DeleteStack is a failed outcome, never silence", async () => {
const target = fakeTarget(OWNED_TAGS);
const apply: AwsHttp = async (_url, form) => {
if (form.Action === "DeleteStack") return { status: 500, text: "throttled" };
return target.apply(_url, form);
};
const result = await executeTeardown(
{ environment: "dev", marker: MARKER, candidates: [CANDIDATE] },
{ read: { http: target.read }, applyHttp: apply, timeoutMs: 1000, intervalMs: 1 },
);
expect(result.outcomes[0]).toMatchObject({ name: "dev", outcome: "failed" });
expect(result.outcomes[0].detail).toMatch(/throttled/);
});
test("a candidate's region comes from the declared stacks", async () => {
// The URL is only regional when no ambient endpoint override is set.
const saved = {
all: process.env.AWS_ENDPOINT_URL,
cfn: process.env.AWS_ENDPOINT_URL_CLOUDFORMATION,
};
delete process.env.AWS_ENDPOINT_URL;
delete process.env.AWS_ENDPOINT_URL_CLOUDFORMATION;
const urls: string[] = [];
const target = fakeTarget(OWNED_TAGS);
const apply: AwsHttp = async (url, form) => {
urls.push(url);
return target.apply(url, form);
};
const read: AwsReadHttp = async (url, init) => {
urls.push(url);
return target.read(url, init);
};
await executeTeardown(
{
environment: "dev",
marker: MARKER,
candidates: [{ ...CANDIDATE, name: "app" }],
stacks: [{ name: "app", region: "eu-west-1" }],
},
{ read: { http: read }, applyHttp: apply, timeoutMs: 1000, intervalMs: 1 },
);
if (saved.all !== undefined) process.env.AWS_ENDPOINT_URL = saved.all;
if (saved.cfn !== undefined) process.env.AWS_ENDPOINT_URL_CLOUDFORMATION = saved.cfn;
expect(urls.length).toBeGreaterThan(0);
expect(urls.every((u) => u.includes("eu-west-1"))).toBe(true);
});
});