1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as frauddetector from "@distilled.cloud/aws/frauddetector";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import FraudDetectorTestFunctionLive, {
11 FRAUD_EMAIL,
12 FraudDetectorTestFunction,
13 REVIEW_OUTCOME,
14 SEED_BLOCKED_IP,
15} from "./fixtures/handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19
20// The standing test account has no frauddetector:* IAM grant, so every live
21// call surfaces AccessDeniedException. The full rule-based E2E is gated behind
22// AWS_TEST_FRAUDDETECTOR=1 and runs unchanged in an entitled account; an
23// ungated typed-error probe proves credentials reach the service and the SDK
24// decodes a typed error either way.
25const RUN_LIVE = !!process.env.AWS_TEST_FRAUDDETECTOR;
26
27// Ungated typed-error probe: proves credentials reach Fraud Detector and the
28// SDK decodes a typed error. In an entitled account the missing detector
29// surfaces as ResourceNotFoundException; an ungranted account surfaces
30// AccessDeniedException. Either way the boundary is a typed tag.
31test.provider(
32 "getDetectors on a nonexistent id fails with a typed error",
33 () =>
34 Effect.gen(function* () {
35 const error = yield* Effect.flip(
36 frauddetector.getDetectors({ detectorId: "does_not_exist_detector" }),
37 );
38 expect(
39 ["ResourceNotFoundException", "AccessDeniedException"].includes(
40 error._tag,
41 ),
42 ).toBe(true);
43 }),
44 { tags: ["provider:aws", "provider:aws:frauddetector", "live"] },
45);
46
47// Ungated typed-error probe for the event data plane: getEvent on a
48// nonexistent event type decodes to a typed tag (ResourceNotFoundException in
49// an entitled account, AccessDeniedException in an ungranted one).
50test.provider(
51 "getEvent on a nonexistent event type fails with a typed error",
52 () =>
53 Effect.gen(function* () {
54 const error = yield* Effect.flip(
55 frauddetector.getEvent({
56 eventId: "does-not-exist",
57 eventTypeName: "does_not_exist_event_type",
58 }),
59 );
60 expect(
61 ["ResourceNotFoundException", "AccessDeniedException"].includes(
62 error._tag,
63 ),
64 ).toBe(true);
65 }),
66 { tags: ["provider:aws", "provider:aws:frauddetector", "live"] },
67);
68
69// Ungated typed-error probe for the list data plane: updateList on a
70// nonexistent list decodes to a typed tag (ResourceNotFoundException in an
71// entitled account, AccessDeniedException in an ungranted one).
72test.provider(
73 "updateList on a nonexistent list fails with a typed error",
74 () =>
75 Effect.gen(function* () {
76 const error = yield* Effect.flip(
77 frauddetector.updateList({
78 name: "does_not_exist_list",
79 elements: ["203.0.113.1"],
80 updateMode: "REPLACE",
81 }),
82 );
83 expect(
84 ["ResourceNotFoundException", "AccessDeniedException"].includes(
85 error._tag,
86 ),
87 ).toBe(true);
88 }),
89 { tags: ["provider:aws", "provider:aws:frauddetector", "live"] },
90);
91
92const sharedStack = Core.scratchStack(testOptions, "FraudDetectorPrediction");
93
94let baseUrl: string;
95
96describe(
97 "FraudDetector GetEventPrediction (E2E)",
98 {
99 tags: [
100 "provider:aws",
101 "provider:aws:frauddetector",
102 "provider:aws:lambda",
103 "live",
104 ],
105 },
106 () => {
107 beforeAll(
108 Effect.gen(function* () {
109 if (!RUN_LIVE) return;
110 yield* Effect.logInfo(
111 "FraudDetector E2E setup: destroying previous run",
112 );
113 yield* sharedStack.destroy();
114
115 yield* Effect.logInfo(
116 "FraudDetector E2E setup: deploying entity type + variables + outcome + event type + detector + active version + Lambda",
117 );
118 const { functionUrl } = yield* sharedStack.deploy(
119 Effect.gen(function* () {
120 return yield* FraudDetectorTestFunction;
121 }).pipe(Effect.provide(FraudDetectorTestFunctionLive)),
122 );
123
124 expect(functionUrl).toBeTruthy();
125 baseUrl = functionUrl!.replace(/\/+$/, "");
126
127 // Readiness probe — fresh function URLs take seconds to serve 200s.
128 yield* HttpClient.get(`${baseUrl}/health`).pipe(
129 Effect.flatMap((response) =>
130 response.status === 200
131 ? Effect.succeed(response)
132 : Effect.fail(
133 new Error(`Function not ready: ${response.status}`),
134 ),
135 ),
136 Effect.retry({
137 schedule: Schedule.max([
138 Schedule.fixed("2 seconds"),
139 Schedule.recurs(60),
140 ]),
141 }),
142 );
143 }),
144 { timeout: 300_000 },
145 );
146 afterAll(
147 Effect.gen(function* () {
148 if (!RUN_LIVE) return;
149 yield* sharedStack.destroy();
150 }),
151 { timeout: 300_000 },
152 );
153
154 test.provider.skipIf(!RUN_LIVE)(
155 "a fraud email is scored and returns the review outcome",
156 () =>
157 Effect.gen(function* () {
158 // The detector version may take a moment to become ACTIVE after
159 // deploy; retry through transient 5xx while it settles.
160 const response = yield* HttpClient.execute(
161 HttpClientRequest.post(`${baseUrl}/predict`).pipe(
162 HttpClientRequest.bodyJsonUnsafe({ email: FRAUD_EMAIL }),
163 ),
164 ).pipe(
165 Effect.flatMap((res) =>
166 res.status === 200
167 ? Effect.succeed(res)
168 : Effect.fail(new Error(`predict failed: ${res.status}`)),
169 ),
170 Effect.retry({
171 schedule: Schedule.max([
172 Schedule.exponential("2 seconds"),
173 Schedule.recurs(8),
174 ]),
175 }),
176 );
177 const body = (yield* response.json) as { outcomes: string[] };
178 expect(body.outcomes).toContain(REVIEW_OUTCOME);
179 }),
180 { timeout: 180_000 },
181 );
182
183 test.provider.skipIf(!RUN_LIVE)(
184 "a benign email matches no rule and returns no outcome",
185 () =>
186 Effect.gen(function* () {
187 const response = yield* HttpClient.execute(
188 HttpClientRequest.post(`${baseUrl}/predict`).pipe(
189 HttpClientRequest.bodyJsonUnsafe({ email: "legit@example.com" }),
190 ),
191 ).pipe(
192 Effect.flatMap((res) =>
193 res.status === 200
194 ? Effect.succeed(res)
195 : Effect.fail(new Error(`predict failed: ${res.status}`)),
196 ),
197 Effect.retry({
198 schedule: Schedule.max([
199 Schedule.exponential("2 seconds"),
200 Schedule.recurs(8),
201 ]),
202 }),
203 );
204 const body = (yield* response.json) as { outcomes: string[] };
205 expect(body.outcomes).not.toContain(REVIEW_OUTCOME);
206 }),
207 { timeout: 180_000 },
208 );
209
210 test.provider.skipIf(!RUN_LIVE)(
211 "a stored event can be sent, read, labeled, and deleted",
212 () =>
213 Effect.gen(function* () {
214 const eventId = "alchemy-e2e-stored-event-1";
215 const client = yield* HttpClient.HttpClient;
216
217 const post = (path: string, body: unknown) =>
218 client
219 .execute(
220 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
221 HttpClientRequest.bodyJsonUnsafe(body),
222 ),
223 )
224 .pipe(
225 Effect.flatMap((res) =>
226 res.status === 200
227 ? Effect.succeed(res)
228 : Effect.fail(new Error(`${path} failed: ${res.status}`)),
229 ),
230 Effect.retry({
231 schedule: Schedule.max([
232 Schedule.exponential("2 seconds"),
233 Schedule.recurs(6),
234 ]),
235 }),
236 );
237
238 const readEvent = client
239 .get(`${baseUrl}/event?id=${eventId}`)
240 .pipe(
241 Effect.flatMap((res) =>
242 res.status === 200
243 ? Effect.flatMap(res.json, (body) =>
244 Effect.succeed(
245 body as { found: boolean; currentLabel?: string },
246 ),
247 )
248 : Effect.fail(new Error(`get event failed: ${res.status}`)),
249 ),
250 );
251
252 // Ingest, then poll until the stored event is readable (ingestion is
253 // eventually consistent).
254 yield* post("/event", { eventId, email: "legit@example.com" });
255 const stored = yield* readEvent.pipe(
256 Effect.retry({
257 schedule: Schedule.max([
258 Schedule.spaced("3 seconds"),
259 Schedule.recurs(10),
260 ]),
261 while: (e): boolean => e instanceof Error,
262 }),
263 Effect.repeat({
264 schedule: Schedule.spaced("3 seconds"),
265 until: (body): boolean => body.found,
266 times: 10,
267 }),
268 );
269 expect(stored.found).toBe(true);
270
271 // Label the stored event, then observe the label on read-back.
272 yield* post("/event/label", { eventId });
273 const labeled = yield* readEvent.pipe(
274 Effect.repeat({
275 schedule: Schedule.spaced("3 seconds"),
276 until: (body): boolean => body.currentLabel !== undefined,
277 times: 10,
278 }),
279 );
280 expect(labeled.currentLabel).toBeTruthy();
281
282 // Delete the stored event, then observe it gone.
283 const deleted = yield* client
284 .del(`${baseUrl}/event?id=${eventId}`)
285 .pipe(Effect.flatMap((res) => Effect.succeed(res.status)));
286 expect(deleted).toBe(200);
287 const gone = yield* readEvent.pipe(
288 Effect.repeat({
289 schedule: Schedule.spaced("3 seconds"),
290 until: (body): boolean => !body.found,
291 times: 10,
292 }),
293 );
294 expect(gone.found).toBe(false);
295 }),
296 { timeout: 180_000 },
297 );
298
299 test.provider.skipIf(!RUN_LIVE)(
300 "the deny-list is readable and appendable at runtime",
301 () =>
302 Effect.gen(function* () {
303 const client = yield* HttpClient.HttpClient;
304 const readList = client
305 .get(`${baseUrl}/list`)
306 .pipe(
307 Effect.flatMap((res) =>
308 res.status === 200
309 ? Effect.flatMap(res.json, (body) =>
310 Effect.succeed(body as { elements: string[] }),
311 )
312 : Effect.fail(new Error(`get list failed: ${res.status}`)),
313 ),
314 );
315
316 // The seeded element from the List resource is readable.
317 const seeded = yield* readList.pipe(
318 Effect.retry({
319 schedule: Schedule.max([
320 Schedule.exponential("2 seconds"),
321 Schedule.recurs(6),
322 ]),
323 }),
324 );
325 expect(seeded.elements).toContain(SEED_BLOCKED_IP);
326
327 // Append a new element at runtime, then observe it on read-back.
328 const appended = "198.51.100.77";
329 const post = yield* client.execute(
330 HttpClientRequest.post(`${baseUrl}/list/append`).pipe(
331 HttpClientRequest.bodyJsonUnsafe({ element: appended }),
332 ),
333 );
334 expect(post.status).toBe(200);
335 const after = yield* readList.pipe(
336 Effect.repeat({
337 schedule: Schedule.spaced("3 seconds"),
338 until: (body): boolean => body.elements.includes(appended),
339 times: 10,
340 }),
341 );
342 expect(after.elements).toContain(appended);
343 expect(after.elements).toContain(SEED_BLOCKED_IP);
344 }),
345 { timeout: 180_000 },
346 );
347
348 test.provider.skipIf(!RUN_LIVE)(
349 "past predictions can be listed and audited",
350 () =>
351 Effect.gen(function* () {
352 const client = yield* HttpClient.HttpClient;
353 const eventId = "alchemy-e2e-audited-prediction-1";
354
355 // Make a prediction with a known event id.
356 yield* client
357 .execute(
358 HttpClientRequest.post(`${baseUrl}/predict`).pipe(
359 HttpClientRequest.bodyJsonUnsafe({
360 email: FRAUD_EMAIL,
361 eventId,
362 }),
363 ),
364 )
365 .pipe(
366 Effect.flatMap((res) =>
367 res.status === 200
368 ? Effect.succeed(res)
369 : Effect.fail(new Error(`predict failed: ${res.status}`)),
370 ),
371 Effect.retry({
372 schedule: Schedule.max([
373 Schedule.exponential("2 seconds"),
374 Schedule.recurs(6),
375 ]),
376 }),
377 );
378
379 // The prediction shows up in ListEventPredictions eventually.
380 const listed = yield* client
381 .get(`${baseUrl}/predictions?eventId=${eventId}`)
382 .pipe(
383 Effect.flatMap((res) =>
384 res.status === 200
385 ? Effect.flatMap(res.json, (body) =>
386 Effect.succeed(body as { summaries: unknown[] }),
387 )
388 : Effect.fail(
389 new Error(`list predictions failed: ${res.status}`),
390 ),
391 ),
392 Effect.repeat({
393 schedule: Schedule.spaced("5 seconds"),
394 until: (body): boolean => body.summaries.length > 0,
395 times: 10,
396 }),
397 );
398 expect(listed.summaries.length).toBeGreaterThan(0);
399
400 // Its full evaluation metadata is auditable.
401 const audit = yield* client
402 .get(`${baseUrl}/prediction-metadata?eventId=${eventId}`)
403 .pipe(
404 Effect.flatMap((res) =>
405 res.status === 200
406 ? Effect.flatMap(res.json, (body) =>
407 Effect.succeed(
408 body as {
409 found: boolean;
410 outcomes: string[];
411 ruleCount: number;
412 },
413 ),
414 )
415 : Effect.fail(new Error(`audit failed: ${res.status}`)),
416 ),
417 );
418 expect(audit.found).toBe(true);
419 expect(audit.outcomes).toContain(REVIEW_OUTCOME);
420 expect(audit.ruleCount).toBeGreaterThan(0);
421 }),
422 { timeout: 180_000 },
423 );
424
425 // Runs LAST — the purge deletes all stored events for the event type.
426 test.provider.skipIf(!RUN_LIVE)(
427 "stored events can be bulk purged by event type",
428 () =>
429 Effect.gen(function* () {
430 const client = yield* HttpClient.HttpClient;
431
432 const purged = yield* client
433 .post(`${baseUrl}/events/purge`)
434 .pipe(
435 Effect.flatMap((res) =>
436 res.status === 200
437 ? Effect.flatMap(res.json, (body) =>
438 Effect.succeed(body as { status?: string }),
439 )
440 : Effect.fail(new Error(`purge failed: ${res.status}`)),
441 ),
442 );
443 expect(purged.status).toBeTruthy();
444
445 // The companion status call reports the async deletion job.
446 const status = yield* client
447 .get(`${baseUrl}/events/purge-status`)
448 .pipe(
449 Effect.flatMap((res) =>
450 res.status === 200
451 ? Effect.flatMap(res.json, (body) =>
452 Effect.succeed(body as { status?: string }),
453 )
454 : Effect.fail(
455 new Error(`purge status failed: ${res.status}`),
456 ),
457 ),
458 Effect.retry({
459 schedule: Schedule.max([
460 Schedule.exponential("2 seconds"),
461 Schedule.recurs(6),
462 ]),
463 }),
464 );
465 expect(status.status).toBeTruthy();
466 }),
467 { timeout: 180_000 },
468 );
469 },
470);