@intentius/chant-lexicon-aws 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,484 @@
1
+ /**
2
+ * AWS deep observation (#1015) — the reference row of the deep-observe contract
3
+ * (#1014).
4
+ *
5
+ * Every AWS interaction here is a mocked `spawn`. Nothing constructs a client,
6
+ * reads ambient credentials, or reaches a network: the reader's only edge is
7
+ * the runtime adapter, and it is replaced wholesale below.
8
+ */
9
+ import { describe, test, expect, vi, beforeEach } from "vitest";
10
+
11
+ // Partial mock (`importOriginal`) for the same reason lifecycle-integration.test.ts
12
+ // uses one: this module is reachable from other real exports the plugin path
13
+ // touches, so replacing it wholesale breaks things unrelated to `spawn`.
14
+ const spawnMock = vi.fn();
15
+ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
17
+ return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
18
+ });
19
+
20
+ const { awsPlugin } = await import("./plugin");
21
+ const {
22
+ observeResourcesDeepAws,
23
+ awsDeepNormalizationHooks,
24
+ parseCloudControlResource,
25
+ hasOwnershipMarker,
26
+ } = await import("./deep-observe");
27
+ const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
28
+ const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
29
+
30
+ const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 });
31
+ const fail = (stderr: string) => ({ stdout: "", stderr, exitCode: 255 });
32
+
33
+ /** A `cloudcontrol get-resource` envelope — the model arrives as a JSON string. */
34
+ const cloudControl = (identifier: string, properties: Record<string, unknown>) =>
35
+ ok(JSON.stringify({ ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) } }));
36
+
37
+ const stackResources = (rows: Array<[string, string, string]>) =>
38
+ ok(
39
+ JSON.stringify({
40
+ StackResources: rows.map(([LogicalResourceId, ResourceType, PhysicalResourceId]) => ({
41
+ LogicalResourceId,
42
+ ResourceType,
43
+ PhysicalResourceId,
44
+ ResourceStatus: "CREATE_COMPLETE",
45
+ Timestamp: "2026-01-01T00:00:00Z",
46
+ })),
47
+ }),
48
+ );
49
+
50
+ const entities = (
51
+ record: Record<string, { entityType: string; props: Record<string, unknown> }>,
52
+ ): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
53
+
54
+ const argvOf = (call: unknown[]): string[] => call[0] as string[];
55
+
56
+ describe("parseCloudControlResource", () => {
57
+ test("unwraps the doubly-encoded model", () => {
58
+ expect(parseCloudControlResource(cloudControl("b", { BucketName: "b" }).stdout)).toEqual({
59
+ identifier: "b",
60
+ properties: { BucketName: "b" },
61
+ });
62
+ });
63
+
64
+ test("an unparseable body is a failed read, not an empty resource", () => {
65
+ expect(parseCloudControlResource("not json")).toBeNull();
66
+ expect(parseCloudControlResource(JSON.stringify({ ResourceDescription: {} }))).toBeNull();
67
+ expect(
68
+ parseCloudControlResource(JSON.stringify({ ResourceDescription: { Properties: "{oops" } })),
69
+ ).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe("the aws noise rules", () => {
74
+ test("prunes server-populated names wherever they appear", () => {
75
+ const out = normalizeDeepProperties(
76
+ { Arn: "arn:aws:s3:::b", BucketName: "b", Nested: { RegionalDomainName: "x", Keep: 1 } },
77
+ { entityType: "AWS::S3::Bucket", side: "live", hooks: awsDeepNormalizationHooks },
78
+ );
79
+ expect(out).toEqual({ BucketName: "b", Nested: { Keep: 1 } });
80
+ });
81
+
82
+ test("canonicalizes tag order", () => {
83
+ const out = normalizeDeepProperties(
84
+ { Tags: [{ Key: "team", Value: "b" }, { Key: "env", Value: "a" }] },
85
+ { entityType: "AWS::S3::Bucket", side: "live", hooks: awsDeepNormalizationHooks },
86
+ );
87
+ expect(out.Tags).toEqual([{ Key: "env", Value: "a" }, { Key: "team", Value: "b" }]);
88
+ });
89
+
90
+ test("canonicalizes policy statement and action order", () => {
91
+ const out = normalizeDeepProperties(
92
+ {
93
+ PolicyDocument: {
94
+ Statement: [
95
+ { Sid: "Write", Action: ["s3:PutObject", "s3:DeleteObject"] },
96
+ { Sid: "Read", Action: ["s3:GetObject"] },
97
+ ],
98
+ },
99
+ },
100
+ { entityType: "AWS::IAM::ManagedPolicy", side: "live", hooks: awsDeepNormalizationHooks },
101
+ );
102
+ const statements = (out.PolicyDocument as { Statement: Array<{ Sid: string; Action: string[] }> }).Statement;
103
+ expect(statements.map((s) => s.Sid)).toEqual(["Read", "Write"]);
104
+ expect(statements[1].Action).toEqual(["s3:DeleteObject", "s3:PutObject"]);
105
+ });
106
+
107
+ test("subtracts a service default only where source is silent about the property", () => {
108
+ const declaredNothing = normalizeDeepProperties(
109
+ { Path: "/", MaxSessionDuration: 3600, RoleName: "r" },
110
+ {
111
+ entityType: "AWS::IAM::Role",
112
+ side: "live",
113
+ hooks: awsDeepNormalizationHooks,
114
+ counterpartPaths: new Set(["RoleName"]),
115
+ },
116
+ );
117
+ expect(declaredNothing).toEqual({ RoleName: "r" });
118
+
119
+ const declaredPath = normalizeDeepProperties(
120
+ { Path: "/", RoleName: "r" },
121
+ {
122
+ entityType: "AWS::IAM::Role",
123
+ side: "live",
124
+ hooks: awsDeepNormalizationHooks,
125
+ counterpartPaths: new Set(["Path", "RoleName"]),
126
+ },
127
+ );
128
+ expect(declaredPath).toEqual({ Path: "/", RoleName: "r" });
129
+ });
130
+
131
+ test("a one-sided pass never subtracts defaults — the reader has no declared tree yet", () => {
132
+ const out = normalizeDeepProperties(
133
+ { Path: "/", RoleName: "r" },
134
+ { entityType: "AWS::IAM::Role", side: "live", hooks: awsDeepNormalizationHooks },
135
+ );
136
+ expect(out).toEqual({ Path: "/", RoleName: "r" });
137
+ });
138
+ });
139
+
140
+ describe("hasOwnershipMarker", () => {
141
+ test("reads chant's tag out of the live tree", () => {
142
+ expect(hasOwnershipMarker({ Tags: [{ Key: "chant:managed-by", Value: "chant" }] })).toBe(true);
143
+ expect(hasOwnershipMarker({ Tags: [{ Key: "env", Value: "prod" }] })).toBe(false);
144
+ expect(hasOwnershipMarker({})).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("observeResourcesDeepAws", () => {
149
+ beforeEach(() => {
150
+ // A bare arrow returning the mock would register the mock itself as
151
+ // vitest's cleanup hook, and vitest would then call it with no arguments.
152
+ spawnMock.mockReset();
153
+ });
154
+
155
+ test("reads each resource through cloudcontrol, honoring AWS_ENDPOINT_URL", async () => {
156
+ const previous = process.env.AWS_ENDPOINT_URL;
157
+ process.env.AWS_ENDPOINT_URL = "http://127.0.0.1:5566";
158
+ try {
159
+ spawnMock.mockImplementation((argv: string[]) =>
160
+ Promise.resolve(
161
+ argv.includes("describe-stack-resources")
162
+ ? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
163
+ : cloudControl("acme-assets", { BucketName: "acme-assets" }),
164
+ ),
165
+ );
166
+ const result = normalizeDeepObservation(
167
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
168
+ );
169
+ expect(result.resources.Assets.properties).toEqual({ BucketName: "acme-assets" });
170
+ expect(result.resources.Assets.physicalId).toBe("acme-assets");
171
+ for (const call of spawnMock.mock.calls) {
172
+ expect(argvOf(call)).toContain("--endpoint-url");
173
+ expect(argvOf(call)).toContain("http://127.0.0.1:5566");
174
+ }
175
+ } finally {
176
+ if (previous === undefined) delete process.env.AWS_ENDPOINT_URL;
177
+ else process.env.AWS_ENDPOINT_URL = previous;
178
+ }
179
+ });
180
+
181
+ test("a type with no reader is unsupported-kind, never absent", async () => {
182
+ spawnMock.mockResolvedValue(stackResources([["Queue", "AWS::SQS::Queue", "q-1"]]));
183
+ const result = normalizeDeepObservation(
184
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Queue"] }),
185
+ );
186
+ expect(result.resources).toEqual({});
187
+ expect(result.unobserved.Queue.reason).toBe("unsupported-kind");
188
+ });
189
+
190
+ test("an expired token on the deep read is no-credentials, per resource", async () => {
191
+ spawnMock.mockImplementation((argv: string[]) =>
192
+ Promise.resolve(
193
+ argv.includes("describe-stack-resources")
194
+ ? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
195
+ : fail("An error occurred (ExpiredToken) when calling GetResource: The security token expired"),
196
+ ),
197
+ );
198
+ const result = normalizeDeepObservation(
199
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
200
+ );
201
+ expect(result.unobserved.Assets.reason).toBe("no-credentials");
202
+ expect(result.resources).toEqual({});
203
+ });
204
+
205
+ test("a stack that does not exist yet is a real absence — no properties, no holes", async () => {
206
+ spawnMock.mockResolvedValue(fail("An error occurred (ValidationError): Stack with id prod does not exist"));
207
+ const result = normalizeDeepObservation(
208
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
209
+ );
210
+ expect(result).toEqual({ resources: {}, unobserved: {} });
211
+ });
212
+
213
+ test("any other stack-read failure is a hole for every declared entity", async () => {
214
+ spawnMock.mockResolvedValue(fail("Could not connect to the endpoint URL"));
215
+ const result = normalizeDeepObservation(
216
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"] }),
217
+ );
218
+ expect(Object.keys(result.unobserved)).toEqual(["A", "B"]);
219
+ expect(result.unobserved.A.reason).toBe("read-failed");
220
+ });
221
+
222
+ test("--owned withholds an unmarked resource as `filtered`, not as absent", async () => {
223
+ spawnMock.mockImplementation((argv: string[]) =>
224
+ Promise.resolve(
225
+ argv.includes("describe-stack-resources")
226
+ ? stackResources([
227
+ ["Ours", "AWS::S3::Bucket", "ours"],
228
+ ["Theirs", "AWS::S3::Bucket", "theirs"],
229
+ ])
230
+ : argv.includes("ours")
231
+ ? cloudControl("ours", { BucketName: "ours", Tags: [{ Key: "chant:managed-by", Value: "chant" }] })
232
+ : cloudControl("theirs", { BucketName: "theirs" }),
233
+ ),
234
+ );
235
+ const result = normalizeDeepObservation(
236
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Ours", "Theirs"], owned: true }),
237
+ );
238
+ expect(Object.keys(result.resources)).toEqual(["Ours"]);
239
+ expect(result.unobserved.Theirs.reason).toBe("filtered");
240
+ });
241
+
242
+ test("secret-bearing properties are masked before they reach the tree", async () => {
243
+ spawnMock.mockImplementation((argv: string[]) =>
244
+ Promise.resolve(
245
+ argv.includes("describe-stack-resources")
246
+ ? stackResources([["Role", "AWS::IAM::Role", "app-role"]])
247
+ : cloudControl("app-role", { RoleName: "app-role", ClientSecret: "s3cr3t" }),
248
+ ),
249
+ );
250
+ const result = normalizeDeepObservation(
251
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Role"] }),
252
+ );
253
+ expect(result.resources.Role.properties.ClientSecret).toBe("[REDACTED]");
254
+ expect(JSON.stringify(result)).not.toContain("s3cr3t");
255
+ });
256
+
257
+ test("a multi-stack project reads the stack it was handed", async () => {
258
+ spawnMock.mockResolvedValue(stackResources([]));
259
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["A"], stack: "payments-prod" });
260
+ expect(argvOf(spawnMock.mock.calls[0])).toContain("payments-prod");
261
+ });
262
+ });
263
+
264
+ /**
265
+ * The acceptance test for #1015: the real plugin, a mutated live tree, a
266
+ * baseline, and exactly the genuine drift.
267
+ */
268
+ describe("end to end: declared + mutated live + baseline (#1015)", () => {
269
+ beforeEach(() => {
270
+ // A bare arrow returning the mock would register the mock itself as
271
+ // vitest's cleanup hook, and vitest would then call it with no arguments.
272
+ spawnMock.mockReset();
273
+ });
274
+
275
+ const declared = entities({
276
+ // Declared with two tags and versioning on.
277
+ Assets: {
278
+ entityType: "AWS::S3::Bucket",
279
+ props: {
280
+ BucketName: "acme-assets",
281
+ VersioningConfiguration: { Status: "Enabled" },
282
+ Tags: [
283
+ { Key: "env", Value: "prod" },
284
+ { Key: "team", Value: "payments" },
285
+ ],
286
+ },
287
+ },
288
+ // Declared with two statements, in source order.
289
+ AppRole: {
290
+ entityType: "AWS::IAM::Role",
291
+ props: {
292
+ RoleName: "app-role",
293
+ AssumeRolePolicyDocument: {
294
+ Version: "2012-10-17",
295
+ Statement: [
296
+ { Sid: "Ec2", Effect: "Allow", Action: ["sts:AssumeRole"] },
297
+ { Sid: "Ci", Effect: "Allow", Action: ["sts:AssumeRole", "sts:TagSession"] },
298
+ ],
299
+ },
300
+ },
301
+ },
302
+ // No Cloud Control reader for this type.
303
+ Jobs: { entityType: "AWS::SQS::Queue", props: { QueueName: "jobs" } },
304
+ // The deep read of this one fails outright.
305
+ Perimeter: { entityType: "AWS::EC2::SecurityGroup", props: { GroupDescription: "perimeter" } },
306
+ });
307
+
308
+ const wireMocks = (): void => {
309
+ spawnMock.mockImplementation((argv: string[]) => {
310
+ if (argv.includes("describe-stack-resources")) {
311
+ return Promise.resolve(
312
+ stackResources([
313
+ ["Assets", "AWS::S3::Bucket", "acme-assets"],
314
+ ["AppRole", "AWS::IAM::Role", "app-role"],
315
+ ["Jobs", "AWS::SQS::Queue", "jobs"],
316
+ ["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"],
317
+ ]),
318
+ );
319
+ }
320
+ if (argv.includes("acme-assets")) {
321
+ return Promise.resolve(
322
+ cloudControl("acme-assets", {
323
+ BucketName: "acme-assets",
324
+ // GENUINE: somebody turned versioning off in the console.
325
+ VersioningConfiguration: { Status: "Suspended" },
326
+ // NOISE: tags come back in a different order …
327
+ Tags: [
328
+ { Key: "team", Value: "payments" },
329
+ // … and with one the platform team adds to every bucket.
330
+ { Key: "cost-center", Value: "platform" },
331
+ { Key: "env", Value: "prod" },
332
+ ],
333
+ // NOISE: server-populated.
334
+ Arn: "arn:aws:s3:::acme-assets",
335
+ RegionalDomainName: "acme-assets.s3.us-east-1.amazonaws.com",
336
+ }),
337
+ );
338
+ }
339
+ if (argv.includes("app-role")) {
340
+ return Promise.resolve(
341
+ cloudControl("app-role", {
342
+ RoleName: "app-role",
343
+ // NOISE: statements and actions in a different order than source.
344
+ AssumeRolePolicyDocument: {
345
+ Version: "2012-10-17",
346
+ Statement: [
347
+ { Sid: "Ci", Effect: "Allow", Action: ["sts:TagSession", "sts:AssumeRole"] },
348
+ { Sid: "Ec2", Effect: "Allow", Action: ["sts:AssumeRole"] },
349
+ ],
350
+ },
351
+ // NOISE: provider defaults nobody declared.
352
+ Path: "/",
353
+ MaxSessionDuration: 3600,
354
+ // NOISE: server-populated.
355
+ Arn: "arn:aws:iam::111122223333:role/app-role",
356
+ RoleId: "AROAEXAMPLE",
357
+ CreateDate: "2026-01-01T00:00:00Z",
358
+ }),
359
+ );
360
+ }
361
+ if (argv.includes("sg-01")) {
362
+ return Promise.resolve(fail("An error occurred (ThrottlingException) when calling GetResource"));
363
+ }
364
+ return Promise.resolve(fail("unexpected call"));
365
+ });
366
+ };
367
+
368
+ const baseline = {
369
+ Assets: {
370
+ type: "AWS::S3::Bucket",
371
+ accepted: [
372
+ { path: "Tags[#cost-center].Key", value: "cost-center" },
373
+ { path: "Tags[#cost-center].Value", value: "platform" },
374
+ ],
375
+ },
376
+ };
377
+
378
+ test("exactly the genuine drift surfaces; noise, defaults and the accepted tag do not", async () => {
379
+ wireMocks();
380
+ const result = await deepDiffForLexicon(awsPlugin, {
381
+ environment: "prod",
382
+ buildOutput: "",
383
+ entities: declared,
384
+ baseline,
385
+ });
386
+
387
+ // One finding, one property: the console-flipped versioning setting.
388
+ expect(result.drifted).toEqual([
389
+ {
390
+ name: "Assets",
391
+ type: "AWS::S3::Bucket",
392
+ changes: [
393
+ {
394
+ path: "VersioningConfiguration.Status",
395
+ kind: "changed",
396
+ declared: "Enabled",
397
+ live: "Suspended",
398
+ },
399
+ ],
400
+ },
401
+ ]);
402
+
403
+ // The role is clean: reordering, defaults and server-populated fields are
404
+ // all subtracted.
405
+ expect(result.unchanged).toEqual(["AppRole"]);
406
+
407
+ // The platform team's tag is accepted, so it is reported as suppressed
408
+ // rather than as drift.
409
+ expect(result.accepted.map((e) => e.name)).toEqual(["Assets"]);
410
+ expect(result.accepted[0].changes.map((c) => c.path)).toEqual([
411
+ "Tags[#cost-center].Key",
412
+ "Tags[#cost-center].Value",
413
+ ]);
414
+
415
+ // An unreadable deep read is a hole with a reason — never silence, never
416
+ // noise, and never a create.
417
+ expect(result.unobserved).toEqual([
418
+ {
419
+ name: "Jobs",
420
+ type: "AWS::SQS::Queue",
421
+ reason: "unsupported-kind",
422
+ detail: "no deep reader for AWS::SQS::Queue — Cloud Control coverage is opt-in per type",
423
+ },
424
+ {
425
+ name: "Perimeter",
426
+ type: "AWS::EC2::SecurityGroup",
427
+ reason: "read-failed",
428
+ detail:
429
+ 'cloudcontrol get-resource failed for AWS::EC2::SecurityGroup "sg-01": An error occurred (ThrottlingException) when calling GetResource',
430
+ },
431
+ ]);
432
+ });
433
+
434
+ test("without the baseline the platform tag is drift, and accepting it is what silences it", async () => {
435
+ wireMocks();
436
+ const result = await deepDiffForLexicon(awsPlugin, {
437
+ environment: "prod",
438
+ buildOutput: "",
439
+ entities: declared,
440
+ });
441
+ const assets = result.drifted.find((d) => d.name === "Assets");
442
+ expect(assets?.changes.map((c) => c.path).sort()).toEqual([
443
+ "Tags[#cost-center].Key",
444
+ "Tags[#cost-center].Value",
445
+ "VersioningConfiguration.Status",
446
+ ]);
447
+ expect(result.accepted).toEqual([]);
448
+ });
449
+
450
+ test("an accepted value that later changes is drift again, with all three axes", async () => {
451
+ wireMocks();
452
+ const result = await deepDiffForLexicon(awsPlugin, {
453
+ environment: "prod",
454
+ buildOutput: "",
455
+ entities: declared,
456
+ baseline: {
457
+ Assets: {
458
+ accepted: [{ path: "Tags[#cost-center].Value", value: "someone-elses-team" }],
459
+ },
460
+ },
461
+ });
462
+ const change = result.drifted
463
+ .find((d) => d.name === "Assets")
464
+ ?.changes.find((c) => c.path === "Tags[#cost-center].Value");
465
+ expect(change).toEqual({
466
+ path: "Tags[#cost-center].Value",
467
+ kind: "undeclared",
468
+ live: "platform",
469
+ baseline: "someone-elses-team",
470
+ });
471
+ });
472
+
473
+ test("a whole-lexicon failure is a hole for every declared entity, not a clean report", async () => {
474
+ spawnMock.mockResolvedValue(fail("Unable to locate credentials"));
475
+ const result = await deepDiffForLexicon(awsPlugin, {
476
+ environment: "prod",
477
+ buildOutput: "",
478
+ entities: declared,
479
+ });
480
+ expect(result.drifted).toEqual([]);
481
+ expect(result.unobserved.map((u) => u.name).sort()).toEqual(["AppRole", "Assets", "Jobs", "Perimeter"]);
482
+ expect(new Set(result.unobserved.map((u) => u.reason))).toEqual(new Set(["no-credentials"]));
483
+ });
484
+ });