Skip to content

Commit a59a1c6

Browse files
author
Oliver Gibbs
committed
test(backend): machine-checkable safety gates for stack restructuring
Seven gates protect any future movement of resources between stacks: a removals-only template diff proving retained resources stay byte-identical, logical-id pins with deletion-policy assertions on all 39 stateful resources, cross-stack resolver parity (every schema field attached exactly once), IAM privilege equivalence proving relocated roles never broaden beyond their baseline, behavioral equivalence on resolver mapping templates and data-source config, plus the existing doc-claims and infrastructure-lint gates — all runnable as one command against a committed synth baseline. Each gate is proven by negative tests: doctored templates with a removed stateful resource, weakened deletion policy, modified key schema, tampered mapping template, broadened IAM statement, and double-attached field each fail their gate.
1 parent 92be6d8 commit a59a1c6

19 files changed

Lines changed: 9319 additions & 1 deletion

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"deploy:backend": "./scripts/deploy.sh BackendStack",
2323
"deploy:all": "./scripts/deploy.sh --all",
2424
"deploy:quick": "npm run build && cdk deploy BackendStack",
25-
"nag": "cdk synth --all --quiet"
25+
"nag": "cdk synth --all --quiet",
26+
"split:gates": "bash scripts/split-gates.sh"
2627
},
2728
"dependencies": {
2829
"@aws-cdk/aws-appsync-alpha": "^2.59.0-alpha.0",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { runRemovalsOnlyDiff } from "../../scripts/split-gates/rails/rail1-removals-only";
2+
import { CfnTemplate } from "../../scripts/split-gates/types";
3+
4+
interface DynamoTableProperties {
5+
TableName: string;
6+
KeySchema: Array<{ AttributeName: string; KeyType: string }>;
7+
}
8+
9+
function baseTemplate(): CfnTemplate {
10+
return {
11+
Resources: {
12+
ProjectsTable: {
13+
Type: "AWS::DynamoDB::Table",
14+
DeletionPolicy: "Delete",
15+
UpdateReplacePolicy: "Delete",
16+
Properties: {
17+
TableName: "citadel-projects-dev",
18+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
19+
},
20+
},
21+
SomeResolver: {
22+
Type: "AWS::AppSync::Resolver",
23+
Properties: { TypeName: "Query", FieldName: "getProject" },
24+
},
25+
SomeFunction: {
26+
Type: "AWS::Lambda::Function",
27+
Properties: {},
28+
},
29+
},
30+
Outputs: {
31+
GraphQLApiUrl: {
32+
Value: "https://example.com/graphql",
33+
Export: { Name: "citadel-backend-dev-GraphQLApiUrl" },
34+
},
35+
},
36+
};
37+
}
38+
39+
describe("rail 1 — removals-only diff (positive)", () => {
40+
it("passes trivially when the fresh template is identical to baseline", () => {
41+
const template = baseTemplate();
42+
const result = runRemovalsOnlyDiff(template, template, []);
43+
expect(result.passed).toBe(true);
44+
expect(result.violations).toHaveLength(0);
45+
});
46+
47+
it("passes when a non-stateful resource is removed with an allowlist justification", () => {
48+
const baseline = baseTemplate();
49+
const fresh = baseTemplate();
50+
delete fresh.Resources.SomeResolver;
51+
const result = runRemovalsOnlyDiff(baseline, fresh, [
52+
{
53+
logicalId: "SomeResolver",
54+
justification: "moved to satellite in a later stage",
55+
},
56+
]);
57+
expect(result.passed).toBe(true);
58+
});
59+
});
60+
61+
describe("rail 1 — removals-only diff (negative: doctored templates must FAIL correctly)", () => {
62+
it("FAILS when a stateful logical ID is removed, even with an allowlist entry", () => {
63+
const baseline = baseTemplate();
64+
const fresh = baseTemplate();
65+
delete fresh.Resources.ProjectsTable;
66+
const result = runRemovalsOnlyDiff(baseline, fresh, [
67+
{
68+
logicalId: "ProjectsTable",
69+
justification: "attempted removal — must still fail",
70+
},
71+
]);
72+
expect(result.passed).toBe(false);
73+
expect(
74+
result.violations.some(
75+
(v) => v.logicalId === "ProjectsTable" && /[Ss]tateful/.test(v.message),
76+
),
77+
).toBe(true);
78+
});
79+
80+
it("FAILS when a non-stateful resource is removed without an allowlist entry", () => {
81+
const baseline = baseTemplate();
82+
const fresh = baseTemplate();
83+
delete fresh.Resources.SomeResolver;
84+
const result = runRemovalsOnlyDiff(baseline, fresh, []);
85+
expect(result.passed).toBe(false);
86+
expect(result.violations.some((v) => v.logicalId === "SomeResolver")).toBe(
87+
true,
88+
);
89+
});
90+
91+
it("FAILS when a new logical ID is added (removals-only violated)", () => {
92+
const baseline = baseTemplate();
93+
const fresh = baseTemplate();
94+
fresh.Resources.NewSurpriseFunction = {
95+
Type: "AWS::Lambda::Function",
96+
Properties: {},
97+
};
98+
const result = runRemovalsOnlyDiff(baseline, fresh, []);
99+
expect(result.passed).toBe(false);
100+
expect(
101+
result.violations.some((v) => v.logicalId === "NewSurpriseFunction"),
102+
).toBe(true);
103+
});
104+
105+
it("FAILS when a retained stateful table's key schema is modified", () => {
106+
const baseline = baseTemplate();
107+
const fresh = baseTemplate();
108+
(
109+
fresh.Resources.ProjectsTable
110+
.Properties as unknown as DynamoTableProperties
111+
).KeySchema = [{ AttributeName: "orgId", KeyType: "HASH" }];
112+
const result = runRemovalsOnlyDiff(baseline, fresh, []);
113+
expect(result.passed).toBe(false);
114+
expect(result.violations.some((v) => v.logicalId === "ProjectsTable")).toBe(
115+
true,
116+
);
117+
});
118+
119+
it("FAILS when a stateful table's DeletionPolicy is weakened", () => {
120+
const baseline = baseTemplate();
121+
baseline.Resources.ProjectsTable.DeletionPolicy = "Retain";
122+
const fresh = baseTemplate();
123+
fresh.Resources.ProjectsTable.DeletionPolicy = "Delete";
124+
const result = runRemovalsOnlyDiff(baseline, fresh, []);
125+
expect(result.passed).toBe(false);
126+
expect(
127+
result.violations.some(
128+
(v) =>
129+
v.logicalId === "ProjectsTable" && /DeletionPolicy/.test(v.message),
130+
),
131+
).toBe(true);
132+
});
133+
134+
it("FAILS when a consumed export is removed", () => {
135+
const baseline = baseTemplate();
136+
const fresh = baseTemplate();
137+
delete fresh.Outputs!.GraphQLApiUrl;
138+
const result = runRemovalsOnlyDiff(baseline, fresh, []);
139+
expect(result.passed).toBe(false);
140+
expect(result.violations.some((v) => v.logicalId === "GraphQLApiUrl")).toBe(
141+
true,
142+
);
143+
});
144+
145+
it("FAILS when an export's value changes without the name changing", () => {
146+
const baseline = baseTemplate();
147+
const fresh = baseTemplate();
148+
fresh.Outputs!.GraphQLApiUrl.Value = "https://evil.example.com/graphql";
149+
const result = runRemovalsOnlyDiff(baseline, fresh, []);
150+
expect(result.passed).toBe(false);
151+
expect(result.violations.some((v) => v.logicalId === "GraphQLApiUrl")).toBe(
152+
true,
153+
);
154+
});
155+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { buildBaseline } from "../../scripts/split-gates/baseline-builder";
2+
import { CfnTemplate } from "../../scripts/split-gates/types";
3+
import {
4+
isStatefulType,
5+
keyPropsEqual,
6+
} from "../../scripts/split-gates/template-utils";
7+
import { STATEFUL_KEY_PROPS } from "../../scripts/split-gates/types";
8+
9+
interface DynamoTableProperties {
10+
TableName: string;
11+
KeySchema: Array<{ AttributeName: string; KeyType: string }>;
12+
AttributeDefinitions?: Array<{
13+
AttributeName: string;
14+
AttributeType: string;
15+
}>;
16+
}
17+
18+
/**
19+
* Rail 2 is implemented as a Jest/CDK-assertions test in
20+
* test/split-gates-rail2-stateful-pin.test.ts, driven by the committed
21+
* baseline + a live cdk.out synth. This suite exercises the same
22+
* comparison logic (buildBaseline + keyPropsEqual) directly against
23+
* doctored in-memory templates, so the "removed stateful ID" and "modified
24+
* key property" failure modes are covered without depending on a real
25+
* cdk synth being present in the test environment.
26+
*/
27+
function baseTemplate(): CfnTemplate {
28+
return {
29+
Resources: {
30+
ProjectsTable: {
31+
Type: "AWS::DynamoDB::Table",
32+
DeletionPolicy: "Delete",
33+
UpdateReplacePolicy: "Delete",
34+
Properties: {
35+
TableName: "citadel-projects-dev",
36+
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
37+
AttributeDefinitions: [{ AttributeName: "id", AttributeType: "S" }],
38+
},
39+
},
40+
ADRsTable: {
41+
Type: "AWS::DynamoDB::Table",
42+
DeletionPolicy: "Retain",
43+
UpdateReplacePolicy: "Retain",
44+
Properties: {
45+
TableName: "citadel-adrs-dev",
46+
KeySchema: [{ AttributeName: "adrId", KeyType: "HASH" }],
47+
},
48+
},
49+
},
50+
};
51+
}
52+
53+
function statefulLogicalIds(template: CfnTemplate): string[] {
54+
return Object.entries(template.Resources)
55+
.filter(([, r]) => isStatefulType(r.Type))
56+
.map(([id]) => id);
57+
}
58+
59+
describe("rail 2 — stateful logical-ID pin (positive, direct comparison logic)", () => {
60+
it("passes when the fresh template retains every stateful ID unchanged", () => {
61+
const baseline = buildBaseline("citadel-backend-dev", baseTemplate());
62+
const fresh = buildBaseline("citadel-backend-dev", baseTemplate());
63+
for (const id of statefulLogicalIds(baseTemplate())) {
64+
expect(fresh.resources[id]).toBeDefined();
65+
expect(fresh.resources[id].deletionPolicy).toBe(
66+
baseline.resources[id].deletionPolicy,
67+
);
68+
}
69+
});
70+
});
71+
72+
describe("rail 2 — stateful logical-ID pin (negative: doctored templates must FAIL correctly)", () => {
73+
it("FAILS (missing) when a stateful logical ID is removed from the fresh template", () => {
74+
const baseline = buildBaseline("citadel-backend-dev", baseTemplate());
75+
const doctored = baseTemplate();
76+
delete doctored.Resources.ADRsTable;
77+
const fresh = buildBaseline("citadel-backend-dev", doctored);
78+
79+
expect(baseline.resources.ADRsTable).toBeDefined();
80+
expect(fresh.resources.ADRsTable).toBeUndefined();
81+
});
82+
83+
it("FAILS (policy changed) when a RETAIN table's DeletionPolicy is weakened to Delete", () => {
84+
const baseline = buildBaseline("citadel-backend-dev", baseTemplate());
85+
const doctored = baseTemplate();
86+
doctored.Resources.ADRsTable.DeletionPolicy = "Delete";
87+
doctored.Resources.ADRsTable.UpdateReplacePolicy = "Delete";
88+
const fresh = buildBaseline("citadel-backend-dev", doctored);
89+
90+
expect(baseline.resources.ADRsTable.deletionPolicy).toBe("Retain");
91+
expect(fresh.resources.ADRsTable.deletionPolicy).toBe("Delete");
92+
expect(fresh.resources.ADRsTable.deletionPolicy).not.toBe(
93+
baseline.resources.ADRsTable.deletionPolicy,
94+
);
95+
});
96+
97+
it("FAILS (key props changed) when a stateful table's KeySchema is modified", () => {
98+
const baseline = buildBaseline("citadel-backend-dev", baseTemplate());
99+
const doctored = baseTemplate();
100+
(
101+
doctored.Resources.ProjectsTable
102+
.Properties as unknown as DynamoTableProperties
103+
).KeySchema = [{ AttributeName: "orgId", KeyType: "HASH" }];
104+
const fresh = buildBaseline("citadel-backend-dev", doctored);
105+
106+
const { equal, diffs } = keyPropsEqual(
107+
baseline.resources.ProjectsTable.properties,
108+
fresh.resources.ProjectsTable.properties,
109+
STATEFUL_KEY_PROPS["AWS::DynamoDB::Table"],
110+
);
111+
expect(equal).toBe(false);
112+
expect(diffs).toContain("KeySchema");
113+
});
114+
});

0 commit comments

Comments
 (0)