1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as detective from "@distilled.cloud/aws/detective";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import DetectiveTestFunctionLive, { DetectiveTestFunction } from "./handler";
12import { makeDetectiveTestLease } from "./TestLease.ts";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "DetectiveBindings");
17const testLease = makeDetectiveTestLease();
18
19beforeAll(testLease.acquire, { timeout: 240_000 });
20afterAll(testLease.release);
21
22// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
23// over 60s on a fresh deploy.
24const readinessPolicy = Schedule.max([
25 Schedule.fixed("2 seconds"),
26 Schedule.recurs(75),
27]);
28
29let baseUrl: string;
30let roleArn: string;
31let graphArn: string;
32// The Detective behavior graph is an account/region singleton. If the
33// account already runs a graph this suite did not create, deploying the
34// fixture would adopt (and later DELETE) it — so every test degrades to a
35// logged no-op instead (capture-and-restore safety, mirroring Graph.test.ts).
36let foreignGraphArn: string | undefined;
37
38class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
39 readonly status: number;
40 readonly body: string;
41}> {}
42
43// The shared Lambda fixture occasionally answers a transient 5xx under load
44// (cold re-init, IAM propagation on the freshly attached policy that the
45// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
46// 4xx/assertion failure surfaces immediately.
47const send = (request: HttpClientRequest.HttpClientRequest) =>
48 HttpClient.execute(request).pipe(
49 Effect.flatMap((response) =>
50 response.status >= 500
51 ? response.text.pipe(
52 Effect.flatMap((body) =>
53 Effect.fail(
54 new TransientUpstream({ status: response.status, body }),
55 ),
56 ),
57 )
58 : Effect.succeed(response),
59 ),
60 Effect.retry({
61 while: (e) => e._tag === "TransientUpstream",
62 schedule: Schedule.max([
63 Schedule.exponential("500 millis"),
64 Schedule.recurs(6),
65 ]),
66 }),
67 );
68
69const getJson = (path: string) =>
70 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
71 Effect.flatMap((r) => r.json),
72 );
73
74const postJson = (path: string) =>
75 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
76 Effect.flatMap((r) => r.json),
77 );
78
79// beforeAll/afterAll hooks run outside `test.provider`'s layer, so raw
80// distilled calls need the provider layer (credentials, region) supplied
81// explicitly.
82const aws = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
83 Core.withProviders(effect, testOptions, sharedStack.name);
84
85const skipForeign = () =>
86 foreignGraphArn
87 ? Effect.logInfo(
88 `Detective graph ${foreignGraphArn} already exists and is not ours — skipping`,
89 ).pipe(Effect.as(true))
90 : Effect.succeed(false);
91
92describe.sequential(
93 "Detective Bindings",
94 {
95 tags: [
96 "provider:aws",
97 "provider:aws:batch",
98 "provider:aws:detective",
99 "provider:aws:lambda",
100 "live",
101 ],
102 },
103 () => {
104 beforeAll(
105 Effect.gen(function* () {
106 // Never take over a behavior graph this fixture did not create.
107 const preexisting = (yield* aws(detective.listGraphs({})))
108 .GraphList?.[0];
109 if (preexisting?.Arn) {
110 const tags = yield* aws(
111 detective.listTagsForResource({
112 ResourceArn: preexisting.Arn,
113 }),
114 );
115 if (tags.Tags?.["fixture"] !== "detective-bindings") {
116 foreignGraphArn = preexisting.Arn;
117 yield* Effect.logInfo(
118 `Detective test setup: foreign graph ${preexisting.Arn} present — suite degrades to no-op`,
119 );
120 return;
121 }
122 }
123
124 yield* Effect.logInfo(
125 "Detective test setup: destroying previous resources",
126 );
127 yield* sharedStack.destroy();
128
129 yield* Effect.logInfo("Detective test setup: deploying fixture");
130 const attrs = yield* sharedStack.deploy(
131 Effect.gen(function* () {
132 return yield* DetectiveTestFunction;
133 }).pipe(Effect.provide(DetectiveTestFunctionLive)),
134 );
135
136 expect(attrs.functionUrl).toBeTruthy();
137 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
138 roleArn = attrs.roleArn;
139
140 // Out-of-band: the fixture created the account's (single) graph.
141 const graphs = yield* aws(detective.listGraphs({}));
142 graphArn = graphs.GraphList?.[0]?.Arn ?? "";
143 expect(graphArn).toContain(":graph:");
144
145 const readinessUrl = `${baseUrl}/bindings`;
146 yield* Effect.logInfo(
147 `Detective test setup: probing readiness at ${readinessUrl}`,
148 );
149 yield* HttpClient.get(readinessUrl).pipe(
150 Effect.flatMap((response) =>
151 response.status === 200
152 ? Effect.succeed(response)
153 : Effect.fail(
154 new Error(`Function not ready: ${response.status}`),
155 ),
156 ),
157 Effect.tapError((error) =>
158 Effect.logWarning(
159 `Detective test setup: fixture not ready yet (${String(error)})`,
160 ),
161 ),
162 Effect.retry({ schedule: readinessPolicy }),
163 );
164 }),
165 { timeout: 240_000 },
166 );
167
168 afterAll(
169 Effect.gen(function* () {
170 if (foreignGraphArn) return;
171 yield* sharedStack.destroy();
172 }),
173 { timeout: 120_000 },
174 );
175
176 describe("binding registration", () => {
177 test.provider("all 23 capabilities initialize in the runtime", (_stack) =>
178 Effect.gen(function* () {
179 if (yield* skipForeign()) return;
180 const response = (yield* getJson("/bindings")) as { bound: string[] };
181 expect(response.bound).toHaveLength(23);
182 expect(response.bound).toContain("startInvestigation");
183 expect(response.bound).toContain("acceptInvitation");
184 expect(response.bound).toContain("enableOrganizationAdminAccount");
185 }),
186 );
187 });
188
189 describe("ListMembers", () => {
190 test.provider(
191 "enumerates the graph's member accounts (injected graph arn)",
192 (_stack) =>
193 Effect.gen(function* () {
194 if (yield* skipForeign()) return;
195 // An organization account may see auto-enabled members even on a
196 // freshly created graph, so assert shape rather than emptiness.
197 const response = (yield* getJson("/members")) as { count: number };
198 expect(typeof response.count).toBe("number");
199 expect(response.count).toBeGreaterThanOrEqual(0);
200 }),
201 );
202 });
203
204 describe("GetMembers", () => {
205 test.provider(
206 "a non-member account comes back as unprocessed (injected graph arn)",
207 (_stack) =>
208 Effect.gen(function* () {
209 if (yield* skipForeign()) return;
210 const response = (yield* getJson("/member-status")) as {
211 members: number;
212 unprocessed: number;
213 };
214 expect(response.members).toBe(0);
215 expect(response.unprocessed).toBe(1);
216 }),
217 );
218 });
219
220 describe("ListDatasourcePackages", () => {
221 test.provider("the graph ingests the core package", (_stack) =>
222 Effect.gen(function* () {
223 if (yield* skipForeign()) return;
224 const response = (yield* getJson("/datasources")) as {
225 packages: string[];
226 };
227 expect(response.packages).toContain("DETECTIVE_CORE");
228 }),
229 );
230 });
231
232 describe("BatchGetGraphMemberDatasources", () => {
233 test.provider("reads member ingest history for the graph", (_stack) =>
234 Effect.gen(function* () {
235 if (yield* skipForeign()) return;
236 const response = (yield* getJson("/graph-member-datasources")) as {
237 memberDatasources?: number;
238 unprocessed?: number;
239 errorTag?: string;
240 };
241 if (response.errorTag) {
242 // A non-member account id may be rejected outright instead of
243 // reported as unprocessed.
244 expect([
245 "ValidationException",
246 "ResourceNotFoundException",
247 ]).toContain(response.errorTag);
248 } else {
249 expect(response.memberDatasources).toBe(0);
250 }
251 }),
252 );
253 });
254
255 describe("ListInvestigations", () => {
256 test.provider("a fresh graph has no investigations", (_stack) =>
257 Effect.gen(function* () {
258 if (yield* skipForeign()) return;
259 const response = (yield* getJson("/investigations")) as {
260 count: number;
261 };
262 expect(response.count).toBe(0);
263 }),
264 );
265 });
266
267 describe("StartInvestigation", () => {
268 test.provider(
269 "triggers triage of the fixture's own role (typed outcome either way)",
270 (_stack) =>
271 Effect.gen(function* () {
272 if (yield* skipForeign()) return;
273 const response = (yield* postJson(
274 `/investigate?entity=${encodeURIComponent(roleArn)}`,
275 )) as { investigationId?: string; errorTag?: string };
276 if (response.errorTag) {
277 // A brand-new graph has ingested (almost) no data, so Detective
278 // typically cannot resolve the entity yet.
279 expect([
280 "ValidationException",
281 "ResourceNotFoundException",
282 "TooManyRequestsException",
283 "InternalServerException",
284 ]).toContain(response.errorTag);
285 } else {
286 expect(response.investigationId).toBeTruthy();
287 }
288 }),
289 );
290 });
291
292 describe("ListInvitations", () => {
293 test.provider(
294 "enumerates this account's behavior-graph invitations",
295 (_stack) =>
296 Effect.gen(function* () {
297 if (yield* skipForeign()) return;
298 // The shared test account can carry standing invitations from
299 // other admin accounts — assert shape rather than emptiness.
300 const response = (yield* getJson("/invitations")) as {
301 count: number;
302 };
303 expect(typeof response.count).toBe("number");
304 expect(response.count).toBeGreaterThanOrEqual(0);
305 }),
306 );
307 });
308
309 describe("BatchGetMembershipDatasources", () => {
310 test.provider(
311 "the admin account is not a member of its own graph",
312 (_stack) =>
313 Effect.gen(function* () {
314 if (yield* skipForeign()) return;
315 const response = (yield* getJson(
316 `/membership-datasources?graphArn=${encodeURIComponent(graphArn)}`,
317 )) as {
318 membershipDatasources?: number;
319 unprocessedGraphs?: number;
320 errorTag?: string;
321 };
322 if (response.errorTag) {
323 expect([
324 "ValidationException",
325 "ResourceNotFoundException",
326 ]).toContain(response.errorTag);
327 } else {
328 expect(
329 (response.membershipDatasources ?? 0) +
330 (response.unprocessedGraphs ?? 0),
331 ).toBeGreaterThanOrEqual(0);
332 }
333 }),
334 );
335 });
336
337 describe("DescribeOrganizationConfiguration", () => {
338 test.provider(
339 "answers (or rejects with a typed error for a non-delegated account)",
340 (_stack) =>
341 Effect.gen(function* () {
342 if (yield* skipForeign()) return;
343 const response = (yield* getJson("/org-config")) as {
344 autoEnable?: boolean;
345 errorTag?: string;
346 };
347 if (response.errorTag) {
348 // Only the organization's delegated Detective administrator may
349 // call this — a standalone test account gets a typed rejection,
350 // which still proves the binding + IAM wiring end-to-end.
351 expect([
352 "ValidationException",
353 "AccessDeniedException",
354 "TooManyRequestsException",
355 ]).toContain(response.errorTag);
356 } else {
357 expect(typeof response.autoEnable).toBe("boolean");
358 }
359 }),
360 );
361 });
362
363 describe("ListOrganizationAdminAccounts", () => {
364 test.provider(
365 "answers (or rejects with a typed error outside the management account)",
366 (_stack) =>
367 Effect.gen(function* () {
368 if (yield* skipForeign()) return;
369 const response = (yield* getJson("/org-admins")) as {
370 administrators?: number;
371 errorTag?: string;
372 };
373 if (response.errorTag) {
374 expect([
375 "ValidationException",
376 "AccessDeniedException",
377 ]).toContain(response.errorTag);
378 } else {
379 expect(response.administrators).toBeGreaterThanOrEqual(0);
380 }
381 }),
382 );
383 });
384 },
385);