@intentius/chant-lexicon-aws 0.34.0 → 0.37.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.
@@ -2,15 +2,17 @@
2
2
  * AWS deep observation (#1015) — the reference row of the deep-observe contract
3
3
  * (#1014).
4
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.
5
+ * Every AWS interaction here is a faked HTTP call (#1206). Nothing spawns a
6
+ * CLI, reads ambient credentials, or reaches a network: the reader's only edge
7
+ * is its transport, which is injected as `http` where the reader is called
8
+ * directly and stubbed at `fetch` where the plugin builds its own.
8
9
  */
9
- import { describe, test, expect, vi, beforeEach } from "vitest";
10
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
10
11
 
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`.
12
+ // The Cloud Control source is injected as `http`; the EC2 source still shells
13
+ // the CLI (#1269), so that one edge is mocked here. Partial mock for the same
14
+ // reason lifecycle-integration.test.ts uses one this module is reachable from
15
+ // real exports the plugin path touches.
14
16
  const spawnMock = vi.fn();
15
17
  vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
16
18
  const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
@@ -21,52 +23,80 @@ const { awsPlugin } = await import("./plugin");
21
23
  const {
22
24
  observeResourcesDeepAws,
23
25
  awsDeepNormalizationHooks,
24
- parseCloudControlResource,
25
26
  hasOwnershipMarker,
26
27
  } = await import("./deep-observe");
28
+ const { parseResourceDescription } = await import("./api/read-client");
27
29
  const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
28
- const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
30
+ const { normalizeDeepObservation, normalizeDeepProperties, flattenDeepProperties } = await import("@intentius/chant/deep-observation");
29
31
 
30
- const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 });
31
- const fail = (stderr: string) => ({ stdout: "", stderr, exitCode: 255 });
32
+ const ok = (text: string) => ({ status: 200, text });
32
33
 
33
- /** A `cloudcontrol get-resource` envelope — the model arrives as a JSON string. */
34
+ /** A Cloud Control `GetResource` body — the model arrives as a JSON string. */
34
35
  const cloudControl = (identifier: string, properties: Record<string, unknown>) =>
35
36
  ok(JSON.stringify({ ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) } }));
36
37
 
38
+ /** A modelled service error, in the shape AWS JSON 1.0 sends it. */
39
+ const apiError = (type: string, message: string, status = 400) =>
40
+ ({ status, text: JSON.stringify({ __type: type, message }) });
41
+
42
+ /** A CloudFormation Query `<Error>` document. */
43
+ const queryError = (code: string, message: string) =>
44
+ ({ status: 400, text: `<ErrorResponse><Error><Code>${code}</Code><Message>${message}</Message></Error></ErrorResponse>` });
45
+
37
46
  const stackResources = (rows: Array<[string, string, string]>) =>
38
47
  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
+ `<DescribeStackResourcesResponse><DescribeStackResourcesResult><StackResources>${rows
49
+ .map(
50
+ ([logicalId, type, physicalId]) =>
51
+ `<member><LogicalResourceId>${logicalId}</LogicalResourceId><ResourceType>${type}</ResourceType>` +
52
+ `<PhysicalResourceId>${physicalId}</PhysicalResourceId><ResourceStatus>CREATE_COMPLETE</ResourceStatus>` +
53
+ `<Timestamp>2026-01-01T00:00:00Z</Timestamp></member>`,
54
+ )
55
+ .join("")}</StackResources></DescribeStackResourcesResult></DescribeStackResourcesResponse>`,
48
56
  );
49
57
 
58
+ type FakeResponse = { status: number; text: string };
59
+ interface FakeCall {
60
+ url: string;
61
+ target?: string;
62
+ body: string;
63
+ }
64
+
65
+ /**
66
+ * A transport fake in place of the old `spawn` fake. `route` sees the Cloud
67
+ * Control identifier (or `undefined` for the CloudFormation call) and returns
68
+ * the response; every call is recorded so a test can assert the endpoint the
69
+ * reader actually reached.
70
+ */
71
+ function httpFake(route: (identifier: string | undefined, call: FakeCall) => FakeResponse) {
72
+ const calls: FakeCall[] = [];
73
+ const http = async (url: string, init: { headers: Record<string, string>; body: string }) => {
74
+ const target = init.headers["x-amz-target"];
75
+ const call: FakeCall = { url, ...(target ? { target } : {}), body: init.body };
76
+ calls.push(call);
77
+ const identifier = target ? (JSON.parse(init.body) as { Identifier?: string }).Identifier : undefined;
78
+ return route(identifier, call);
79
+ };
80
+ return { http, calls };
81
+ }
82
+
50
83
  const entities = (
51
84
  record: Record<string, { entityType: string; props: Record<string, unknown> }>,
52
85
  ): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
53
86
 
54
- const argvOf = (call: unknown[]): string[] => call[0] as string[];
55
87
 
56
- describe("parseCloudControlResource", () => {
88
+ describe("parseResourceDescription", () => {
57
89
  test("unwraps the doubly-encoded model", () => {
58
- expect(parseCloudControlResource(cloudControl("b", { BucketName: "b" }).stdout)).toEqual({
90
+ expect(parseResourceDescription({ Identifier: "b", Properties: JSON.stringify({ BucketName: "b" }) })).toEqual({
59
91
  identifier: "b",
60
92
  properties: { BucketName: "b" },
61
93
  });
62
94
  });
63
95
 
64
96
  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();
97
+ expect(parseResourceDescription("not an object")).toBeNull();
98
+ expect(parseResourceDescription({})).toBeNull();
99
+ expect(parseResourceDescription({ Properties: "{oops" })).toBeNull();
70
100
  });
71
101
  });
72
102
 
@@ -87,6 +117,91 @@ describe("the aws noise rules", () => {
87
117
  expect(out.Tags).toEqual([{ Key: "env", Value: "a" }, { Key: "team", Value: "b" }]);
88
118
  });
89
119
 
120
+ // chant stamps its ownership marker onto the template, so it is live on every
121
+ // managed resource and absent from the declared properties the diff compares.
122
+ // Reporting it is chant reading its own signature back as drift.
123
+ test("chant's own ownership tags are not drift", () => {
124
+ const out = normalizeDeepProperties(
125
+ {
126
+ Tags: [
127
+ { Key: "chant:managed-by", Value: "chant" },
128
+ { Key: "chant:stack", Value: "web" },
129
+ { Key: "chant:env", Value: "prod" },
130
+ { Key: "team", Value: "payments" },
131
+ ],
132
+ },
133
+ {
134
+ entityType: "AWS::EC2::SecurityGroup",
135
+ side: "live",
136
+ hooks: awsDeepNormalizationHooks,
137
+ counterpartPaths: new Set<string>(),
138
+ },
139
+ );
140
+ expect(out.Tags).toEqual([{ Key: "team", Value: "payments" }]);
141
+ });
142
+
143
+ test("a template that declares the marker itself still has it compared", () => {
144
+ const out = normalizeDeepProperties(
145
+ { Tags: [{ Key: "chant:stack", Value: "renamed" }] },
146
+ {
147
+ entityType: "AWS::EC2::SecurityGroup",
148
+ side: "live",
149
+ hooks: awsDeepNormalizationHooks,
150
+ // Source declares the tag, so the counterpart is present and the
151
+ // suppression must not apply — a changed value is real drift.
152
+ counterpartPaths: new Set(["Tags[].Key", "Tags[].Value", "Tags[0]"]),
153
+ },
154
+ );
155
+ expect(out.Tags).toEqual([{ Key: "chant:stack", Value: "renamed" }]);
156
+ });
157
+
158
+ // A rule's canonical JSON is longer than a path segment may carry, so keying
159
+ // by it made the flattener compare rule sets positionally — one added rule
160
+ // then reported as "the first rule changed, and a new one appeared".
161
+ test("keys a security-group rule by protocol, ports and source", () => {
162
+ const out = flattenDeepProperties(
163
+ {
164
+ SecurityGroupIngress: [
165
+ { IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "203.0.113.0/24", Description: "ssh from the office" },
166
+ { IpProtocol: "tcp", FromPort: 443, ToPort: 443, CidrIp: "0.0.0.0/0" },
167
+ ],
168
+ },
169
+ { entityType: "AWS::EC2::SecurityGroup", side: "live", hooks: awsDeepNormalizationHooks },
170
+ );
171
+ const paths = [...out.keys()];
172
+ expect(paths).toContain("SecurityGroupIngress[#tcp:22:22:203.0.113.0/24].CidrIp");
173
+ expect(paths).toContain("SecurityGroupIngress[#tcp:443:443:0.0.0.0/0].CidrIp");
174
+ // Positional segments would mean the set is being compared by position.
175
+ expect(paths.some((p) => p.startsWith("SecurityGroupIngress[0]"))).toBe(false);
176
+ });
177
+
178
+ test("a rule keeps its identity when only its description changes", () => {
179
+ const key = (description: string) =>
180
+ [
181
+ ...flattenDeepProperties(
182
+ { SecurityGroupIngress: [{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "10.0.0.0/8", Description: description }] },
183
+ { entityType: "AWS::EC2::SecurityGroup", side: "live", hooks: awsDeepNormalizationHooks },
184
+ ).keys(),
185
+ ].filter((p) => p.endsWith(".CidrIp"));
186
+ // Editing a description is a change to that rule, not a delete plus an add.
187
+ expect(key("before")).toEqual(key("after"));
188
+ });
189
+
190
+ test("a rule with no recognisable source falls back rather than colliding", () => {
191
+ const out = flattenDeepProperties(
192
+ {
193
+ SecurityGroupIngress: [
194
+ { IpProtocol: "tcp", FromPort: 1, ToPort: 1 },
195
+ { IpProtocol: "udp", FromPort: 2, ToPort: 2 },
196
+ ],
197
+ },
198
+ { entityType: "AWS::EC2::SecurityGroup", side: "live", hooks: awsDeepNormalizationHooks },
199
+ );
200
+ // Two sourceless rules must not key to the same segment; canonical JSON
201
+ // still distinguishes them.
202
+ expect([...out.keys()].filter((p) => p.endsWith(".IpProtocol"))).toHaveLength(2);
203
+ });
204
+
90
205
  test("canonicalizes policy statement and action order", () => {
91
206
  const out = normalizeDeepProperties(
92
207
  {
@@ -146,32 +261,26 @@ describe("hasOwnershipMarker", () => {
146
261
  });
147
262
 
148
263
  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
- });
264
+ beforeEach(() => spawnMock.mockReset());
154
265
 
155
- test("reads each resource through cloudcontrol, honoring AWS_ENDPOINT_URL", async () => {
266
+ test("reads each resource through Cloud Control, honoring AWS_ENDPOINT_URL", async () => {
156
267
  const previous = process.env.AWS_ENDPOINT_URL;
157
268
  process.env.AWS_ENDPOINT_URL = "http://127.0.0.1:5566";
158
269
  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
- ),
270
+ const fake = httpFake((identifier) =>
271
+ identifier === undefined
272
+ ? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
273
+ : cloudControl("acme-assets", { BucketName: "acme-assets" }),
165
274
  );
166
275
  const result = normalizeDeepObservation(
167
- await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
276
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
168
277
  );
169
278
  expect(result.resources.Assets.properties).toEqual({ BucketName: "acme-assets" });
170
279
  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
- }
280
+ // The endpoint override reaches both APIs — no `--endpoint-url` argv to
281
+ // forget, because there is no argv.
282
+ for (const call of fake.calls) expect(call.url).toBe("http://127.0.0.1:5566/");
283
+ expect(fake.calls.map((c) => c.target)).toEqual([undefined, "CloudApiService.GetResource"]);
175
284
  } finally {
176
285
  if (previous === undefined) delete process.env.AWS_ENDPOINT_URL;
177
286
  else process.env.AWS_ENDPOINT_URL = previous;
@@ -179,85 +288,219 @@ describe("observeResourcesDeepAws", () => {
179
288
  });
180
289
 
181
290
  test("a type with no reader is unsupported-kind, never absent", async () => {
182
- spawnMock.mockResolvedValue(stackResources([["Queue", "AWS::SQS::Queue", "q-1"]]));
291
+ const fake = httpFake(() => stackResources([["Queue", "AWS::SQS::Queue", "q-1"]]));
183
292
  const result = normalizeDeepObservation(
184
- await observeResourcesDeepAws({ environment: "prod", entityNames: ["Queue"] }),
293
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Queue"], http: fake.http }),
185
294
  );
186
295
  expect(result.resources).toEqual({});
187
296
  expect(result.unobserved.Queue.reason).toBe("unsupported-kind");
188
297
  });
189
298
 
190
299
  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
- ),
300
+ const fake = httpFake((identifier) =>
301
+ identifier === undefined
302
+ ? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
303
+ : apiError("ExpiredTokenException", "The security token included in the request is expired"),
197
304
  );
198
305
  const result = normalizeDeepObservation(
199
- await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
306
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
200
307
  );
201
308
  expect(result.unobserved.Assets.reason).toBe("no-credentials");
202
309
  expect(result.resources).toEqual({});
203
310
  });
204
311
 
312
+ test("an unsupported operation is a hole for that resource, and says which operation", async () => {
313
+ // What Floci answers for GetResource today: the service is reachable and
314
+ // refuses the call, which is neither absence nor a credential problem.
315
+ const fake = httpFake((identifier) =>
316
+ identifier === undefined
317
+ ? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
318
+ : apiError("UnsupportedOperation", "Operation GetResource is not supported."),
319
+ );
320
+ const result = normalizeDeepObservation(
321
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
322
+ );
323
+ expect(result.unobserved.Assets.reason).toBe("read-failed");
324
+ expect(result.unobserved.Assets.detail).toContain("UnsupportedOperation");
325
+ expect(result.unobserved.Assets.detail).toContain("GetResource");
326
+ });
327
+
205
328
  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"));
329
+ const fake = httpFake(() => queryError("ValidationError", "Stack with id prod does not exist"));
207
330
  const result = normalizeDeepObservation(
208
- await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
331
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
209
332
  );
210
333
  expect(result).toEqual({ resources: {}, unobserved: {} });
211
334
  });
212
335
 
213
336
  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"));
337
+ const fake = httpFake(() => ({ status: 503, text: "<html>service unavailable</html>" }));
215
338
  const result = normalizeDeepObservation(
216
- await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"] }),
339
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"], http: fake.http }),
217
340
  );
218
341
  expect(Object.keys(result.unobserved)).toEqual(["A", "B"]);
219
342
  expect(result.unobserved.A.reason).toBe("read-failed");
220
343
  });
221
344
 
222
345
  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
- ),
346
+ const fake = httpFake((identifier) =>
347
+ identifier === undefined
348
+ ? stackResources([
349
+ ["Ours", "AWS::S3::Bucket", "ours"],
350
+ ["Theirs", "AWS::S3::Bucket", "theirs"],
351
+ ])
352
+ : identifier === "ours"
353
+ ? cloudControl("ours", { BucketName: "ours", Tags: [{ Key: "chant:managed-by", Value: "chant" }] })
354
+ : cloudControl("theirs", { BucketName: "theirs" }),
234
355
  );
235
356
  const result = normalizeDeepObservation(
236
- await observeResourcesDeepAws({ environment: "prod", entityNames: ["Ours", "Theirs"], owned: true }),
357
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Ours", "Theirs"], owned: true, http: fake.http }),
237
358
  );
238
359
  expect(Object.keys(result.resources)).toEqual(["Ours"]);
239
360
  expect(result.unobserved.Theirs.reason).toBe("filtered");
240
361
  });
241
362
 
242
363
  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
- ),
364
+ const fake = httpFake((identifier) =>
365
+ identifier === undefined
366
+ ? stackResources([["Role", "AWS::IAM::Role", "app-role"]])
367
+ : cloudControl("app-role", { RoleName: "app-role", ClientSecret: "s3cr3t" }),
249
368
  );
250
369
  const result = normalizeDeepObservation(
251
- await observeResourcesDeepAws({ environment: "prod", entityNames: ["Role"] }),
370
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Role"], http: fake.http }),
252
371
  );
253
372
  expect(result.resources.Role.properties.ClientSecret).toBe("[REDACTED]");
254
373
  expect(JSON.stringify(result)).not.toContain("s3cr3t");
255
374
  });
256
375
 
257
376
  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");
377
+ const fake = httpFake(() => stackResources([]));
378
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["A"], stack: "payments-prod", http: fake.http });
379
+ expect(fake.calls[0]?.body).toContain("StackName=payments-prod");
380
+ });
381
+
382
+ // #1269 — Cloud Control returns a security group's identity and description
383
+ // and none of its rules, so the type is sourced from the EC2 API instead.
384
+ describe("a type sourced from EC2 rather than Cloud Control", () => {
385
+ const sgRow = (permissions: unknown[]) => ({
386
+ GroupId: "sg-01",
387
+ GroupName: "app-sg",
388
+ Description: "app tier",
389
+ VpcId: "vpc-1",
390
+ Tags: [{ Key: "chant:managed-by", Value: "chant" }],
391
+ IpPermissions: permissions,
392
+ IpPermissionsEgress: [],
393
+ });
394
+ const ssh = {
395
+ IpProtocol: "tcp",
396
+ FromPort: 22,
397
+ ToPort: 22,
398
+ IpRanges: [{ CidrIp: "203.0.113.0/24", Description: "office" }],
399
+ };
400
+
401
+ test("reads the rules Cloud Control does not return, in the template's shape", async () => {
402
+ spawnMock.mockResolvedValue({
403
+ stdout: JSON.stringify({ SecurityGroups: [sgRow([ssh])] }),
404
+ stderr: "",
405
+ exitCode: 0,
406
+ });
407
+ const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
408
+ const result = normalizeDeepObservation(
409
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], http: fake.http }),
410
+ );
411
+ // EC2's `Description`/`IpPermissions` arrive as the CloudFormation model's
412
+ // `GroupDescription`/`SecurityGroupIngress`, one flat rule per source.
413
+ expect(result.resources.Perimeter.properties).toMatchObject({
414
+ GroupDescription: "app tier",
415
+ VpcId: "vpc-1",
416
+ SecurityGroupIngress: [
417
+ { IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "203.0.113.0/24", Description: "office" },
418
+ ],
419
+ });
420
+ // The physical id is server-populated and pruned, as on every other type.
421
+ expect(result.resources.Perimeter.properties.GroupId).toBeUndefined();
422
+ });
423
+
424
+ test("one describe for every group in the stack, not one per group", async () => {
425
+ spawnMock.mockResolvedValue({
426
+ stdout: JSON.stringify({ SecurityGroups: [sgRow([ssh]), { ...sgRow([]), GroupId: "sg-02" }] }),
427
+ stderr: "",
428
+ exitCode: 0,
429
+ });
430
+ const fake = httpFake(() =>
431
+ stackResources([
432
+ ["A", "AWS::EC2::SecurityGroup", "sg-01"],
433
+ ["B", "AWS::EC2::SecurityGroup", "sg-02"],
434
+ ]),
435
+ );
436
+ const result = normalizeDeepObservation(
437
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"], http: fake.http }),
438
+ );
439
+ expect(Object.keys(result.resources).sort()).toEqual(["A", "B"]);
440
+ expect(spawnMock).toHaveBeenCalledTimes(1);
441
+ expect(spawnMock.mock.calls[0][0]).toEqual(expect.arrayContaining(["describe-security-groups", "sg-01", "sg-02"]));
442
+ });
443
+
444
+ test("a failed describe is a hole for that type, never an absence", async () => {
445
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "throttled", exitCode: 255 });
446
+ const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
447
+ const result = normalizeDeepObservation(
448
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], http: fake.http }),
449
+ );
450
+ expect(result.resources).toEqual({});
451
+ expect(result.unobserved.Perimeter.reason).toBe("read-failed");
452
+ });
453
+
454
+ test("a group the describe answered for but did not return is absent, not a hole", async () => {
455
+ // The read succeeded and the id was not in it. The thin path reports that
456
+ // absence; a second report here would turn one finding into two.
457
+ spawnMock.mockResolvedValue({ stdout: JSON.stringify({ SecurityGroups: [] }), stderr: "", exitCode: 0 });
458
+ const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
459
+ const result = normalizeDeepObservation(
460
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], http: fake.http }),
461
+ );
462
+ expect(result.resources).toEqual({});
463
+ expect(result.unobserved).toEqual({});
464
+ });
465
+
466
+ test("--owned reads the marker off the EC2 tags", async () => {
467
+ spawnMock.mockResolvedValue({
468
+ stdout: JSON.stringify({ SecurityGroups: [{ ...sgRow([ssh]), Tags: [{ Key: "team", Value: "other" }] }] }),
469
+ stderr: "",
470
+ exitCode: 0,
471
+ });
472
+ const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
473
+ const result = normalizeDeepObservation(
474
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], owned: true, http: fake.http }),
475
+ );
476
+ expect(result.unobserved.Perimeter.reason).toBe("filtered");
477
+ });
478
+ });
479
+
480
+ test("the per-resource reads are concurrent, not one round trip after another", async () => {
481
+ let inFlight = 0;
482
+ let peak = 0;
483
+ const http = async (_url: string, init: { headers: Record<string, string>; body: string }) => {
484
+ const target = init.headers["x-amz-target"];
485
+ if (!target) {
486
+ return stackResources([
487
+ ["A", "AWS::S3::Bucket", "a"],
488
+ ["B", "AWS::S3::Bucket", "b"],
489
+ ["C", "AWS::S3::Bucket", "c"],
490
+ ]);
491
+ }
492
+ inFlight += 1;
493
+ peak = Math.max(peak, inFlight);
494
+ await new Promise((resolve) => setTimeout(resolve, 5));
495
+ inFlight -= 1;
496
+ const identifier = (JSON.parse(init.body) as { Identifier: string }).Identifier;
497
+ return cloudControl(identifier, { BucketName: identifier });
498
+ };
499
+ const result = normalizeDeepObservation(
500
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B", "C"], http }),
501
+ );
502
+ expect(Object.keys(result.resources).sort()).toEqual(["A", "B", "C"]);
503
+ expect(peak).toBeGreaterThan(1);
261
504
  });
262
505
  });
263
506
 
@@ -266,10 +509,13 @@ describe("observeResourcesDeepAws", () => {
266
509
  * baseline, and exactly the genuine drift.
267
510
  */
268
511
  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();
512
+ // The security group in this estate is sourced from EC2 (#1269), which still
513
+ // shells the CLI; it stands in for a deep read that fails.
514
+ beforeEach(() => spawnMock.mockResolvedValue({ stdout: "", stderr: "throttled", exitCode: 255 }));
515
+ // This block drives the real plugin, which builds its own transport, so the
516
+ // seam here is `fetch` itself rather than an injected `http` (#1206).
517
+ afterEach(() => {
518
+ vi.restoreAllMocks();
273
519
  });
274
520
 
275
521
  const declared = entities({
@@ -306,9 +552,14 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
306
552
  });
307
553
 
308
554
  const wireMocks = (): void => {
309
- spawnMock.mockImplementation((argv: string[]) => {
310
- if (argv.includes("describe-stack-resources")) {
311
- return Promise.resolve(
555
+ vi.spyOn(globalThis, "fetch").mockImplementation((async (_url: string, init: { headers: Record<string, string>; body: string }) => {
556
+ const target = init.headers["x-amz-target"];
557
+ const identifier = target ? (JSON.parse(init.body) as { Identifier: string }).Identifier : undefined;
558
+ const respond = (r: { status: number; text: string }) =>
559
+ ({ status: r.status, text: () => Promise.resolve(r.text) });
560
+
561
+ if (identifier === undefined) {
562
+ return respond(
312
563
  stackResources([
313
564
  ["Assets", "AWS::S3::Bucket", "acme-assets"],
314
565
  ["AppRole", "AWS::IAM::Role", "app-role"],
@@ -317,8 +568,8 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
317
568
  ]),
318
569
  );
319
570
  }
320
- if (argv.includes("acme-assets")) {
321
- return Promise.resolve(
571
+ if (identifier === "acme-assets") {
572
+ return respond(
322
573
  cloudControl("acme-assets", {
323
574
  BucketName: "acme-assets",
324
575
  // GENUINE: somebody turned versioning off in the console.
@@ -336,8 +587,8 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
336
587
  }),
337
588
  );
338
589
  }
339
- if (argv.includes("app-role")) {
340
- return Promise.resolve(
590
+ if (identifier === "app-role") {
591
+ return respond(
341
592
  cloudControl("app-role", {
342
593
  RoleName: "app-role",
343
594
  // NOISE: statements and actions in a different order than source.
@@ -358,11 +609,11 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
358
609
  }),
359
610
  );
360
611
  }
361
- if (argv.includes("sg-01")) {
362
- return Promise.resolve(fail("An error occurred (ThrottlingException) when calling GetResource"));
612
+ if (identifier === "sg-01") {
613
+ return respond(apiError("ValidationException", "sg-01 is read through EC2, not Cloud Control"));
363
614
  }
364
- return Promise.resolve(fail("unexpected call"));
365
- });
615
+ return respond(apiError("ValidationException", `unexpected call for ${identifier}`));
616
+ }) as unknown as typeof fetch);
366
617
  };
367
618
 
368
619
  const baseline = {
@@ -419,14 +670,14 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
419
670
  name: "Jobs",
420
671
  type: "AWS::SQS::Queue",
421
672
  reason: "unsupported-kind",
422
- detail: "no deep reader for AWS::SQS::Queue — Cloud Control coverage is opt-in per type",
673
+ detail: "no deep reader for AWS::SQS::Queue — coverage is opt-in per type",
423
674
  },
424
675
  {
425
676
  name: "Perimeter",
426
677
  type: "AWS::EC2::SecurityGroup",
427
678
  reason: "read-failed",
428
679
  detail:
429
- 'cloudcontrol get-resource failed for AWS::EC2::SecurityGroup "sg-01": An error occurred (ThrottlingException) when calling GetResource',
680
+ "ec2 describe-security-groups failed, so AWS::EC2::SecurityGroup could not be read deeply",
430
681
  },
431
682
  ]);
432
683
  });
@@ -471,7 +722,14 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
471
722
  });
472
723
 
473
724
  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"));
725
+ // The stack read itself is refused, so nothing downstream ever runs. CFN
726
+ // speaks the Query protocol, so the refusal arrives as an `<Error>`
727
+ // document rather than the JSON one Cloud Control would send.
728
+ const refused = queryError("AccessDenied", "User is not authorized to perform cloudformation:DescribeStackResources");
729
+ vi.spyOn(globalThis, "fetch").mockImplementation((async () => ({
730
+ status: refused.status,
731
+ text: () => Promise.resolve(refused.text),
732
+ })) as unknown as typeof fetch);
475
733
  const result = await deepDiffForLexicon(awsPlugin, {
476
734
  environment: "prod",
477
735
  buildOutput: "",