1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
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 RekognitionTestFunctionLive, {
11 RekognitionTestFunction,
12 TEST_COLLECTION_ID,
13} from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "RekognitionBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy under parallel-suite load.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26let baseUrl: string;
27// Parsed from the deployed function's ARN; the /custom-labels route uses it
28// to build well-formed same-account project ARNs.
29let accountId: string;
30
31class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
32 readonly status: number;
33 readonly body: string;
34}> {
35 override get message(): string {
36 return `upstream responded ${this.status}: ${this.body}`;
37 }
38}
39
40// Freshly attached IAM policies propagate eventually; an early Rekognition
41// call can surface AccessDenied as a 500 through the handler's orDie. Retry
42// 5xx only; a genuine 4xx/assertion failure surfaces immediately.
43const send = (request: HttpClientRequest.HttpClientRequest) =>
44 HttpClient.execute(request).pipe(
45 Effect.flatMap((response) =>
46 response.status >= 500
47 ? response.text.pipe(
48 Effect.flatMap((body) =>
49 Effect.fail(
50 new TransientUpstream({ status: response.status, body }),
51 ),
52 ),
53 )
54 : Effect.succeed(response),
55 ),
56 Effect.retry({
57 while: (e): boolean => e._tag === "TransientUpstream",
58 schedule: Schedule.max([
59 Schedule.exponential("1 second"),
60 Schedule.recurs(6),
61 ]),
62 }),
63 );
64
65const getJson = (path: string) =>
66 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
67 Effect.flatMap((r) => r.json),
68 );
69
70const postJson = (path: string) =>
71 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
72 Effect.flatMap((r) => r.json),
73 );
74
75describe(
76 "Rekognition Bindings",
77 {
78 tags: [
79 "provider:aws",
80 "provider:aws:lambda",
81 "provider:aws:rekognition",
82 "live",
83 ],
84 },
85 () => {
86 beforeAll(
87 Effect.gen(function* () {
88 yield* Effect.logInfo(
89 "Rekognition test setup: destroying previous resources",
90 );
91 yield* sharedStack.destroy();
92
93 yield* Effect.logInfo("Rekognition test setup: deploying fixture");
94 const { functionUrl, functionArn } = yield* sharedStack.deploy(
95 Effect.gen(function* () {
96 return yield* RekognitionTestFunction;
97 }).pipe(Effect.provide(RekognitionTestFunctionLive)),
98 );
99
100 expect(functionUrl).toBeTruthy();
101 baseUrl = functionUrl!.replace(/\/+$/, "");
102 // arn:aws:lambda:{region}:{account}:function:{name}
103 accountId = functionArn.split(":")[4]!;
104 const readinessUrl = `${baseUrl}/ping`;
105
106 yield* Effect.logInfo(
107 `Rekognition test setup: probing readiness at ${readinessUrl}`,
108 );
109 yield* HttpClient.get(readinessUrl).pipe(
110 Effect.flatMap((response) =>
111 response.status === 200
112 ? Effect.succeed(response)
113 : Effect.fail(
114 new Error(`Function not ready: ${response.status}`),
115 ),
116 ),
117 Effect.tapError((error) =>
118 Effect.logWarning(
119 `Rekognition test setup: fixture not ready yet (${String(error)})`,
120 ),
121 ),
122 Effect.retry({ schedule: readinessPolicy }),
123 );
124 }),
125 { timeout: 240_000 },
126 );
127
128 // NO_DESTROY=1 keeps the deployment alive between runs while iterating.
129 if (!process.env.NO_DESTROY) {
130 afterAll(sharedStack.destroy(), { timeout: 180_000 });
131 }
132
133 describe("image analysis (DetectLabels, DetectFaces, DetectModerationLabels, DetectText, DetectProtectiveEquipment, RecognizeCelebrities, CompareFaces, GetCelebrityInfo)", () => {
134 test.provider(
135 "every synchronous image binding runs a real inference",
136 (_stack) =>
137 Effect.gen(function* () {
138 const result = (yield* getJson("/analyze-image")) as {
139 labelNames: string[];
140 faceCount: number;
141 moderationCount: number;
142 textCount: number;
143 ppePersons: number;
144 celebrityCount: number;
145 compareTag: string;
146 celebrityInfoTag: string;
147 };
148 // The synthetic scene reliably produces at least one label.
149 expect(result.labelNames.length).toBeGreaterThan(0);
150 // No faces / unsafe content / text / persons in the test image —
151 // the calls succeeding (not 500ing) is the IAM + plumbing proof.
152 expect(result.faceCount).toBe(0);
153 expect(result.moderationCount).toBeGreaterThanOrEqual(0);
154 expect(result.textCount).toBe(0);
155 expect(result.ppePersons).toBe(0);
156 expect(result.celebrityCount).toBe(0);
157 // CompareFaces on a faceless source image surfaces its typed
158 // InvalidParameterException.
159 expect(result.compareTag).toBe("InvalidParameterException");
160 expect([
161 "ResourceNotFoundException",
162 "InvalidParameterException",
163 ]).toContain(result.celebrityInfoTag);
164 }),
165 { timeout: 120_000 },
166 );
167 });
168
169 describe("face collections + user search (CreateCollection, DescribeCollection, ListCollections, IndexFaces, ListFaces, DeleteFaces, SearchFaces, SearchFacesByImage, CreateUser, ListUsers, AssociateFaces, DisassociateFaces, SearchUsers, SearchUsersByImage, DeleteUser, DeleteCollection)", () => {
170 test.provider(
171 "runs a real collection + user lifecycle end-to-end",
172 (_stack) =>
173 Effect.gen(function* () {
174 const result = (yield* postJson("/collections")) as {
175 createStatus: number;
176 faceCountAtCreate: number;
177 listedCollection: boolean;
178 indexedFaceRecords: number;
179 listedFaces: number;
180 listedUsers: string[];
181 searchUsersTag: string;
182 associateTag: string;
183 disassociateTag: string;
184 searchFacesTag: string;
185 searchFacesByImageTag: string;
186 searchUsersByImageTag: string;
187 deleteFacesTag: string;
188 };
189 expect(result.createStatus).toBe(200);
190 expect(result.faceCountAtCreate).toBe(0);
191 expect(result.listedCollection).toBe(true);
192 // The test image has no faces: IndexFaces succeeds with zero
193 // records and the by-image searches surface the typed
194 // InvalidParameterException.
195 expect(result.indexedFaceRecords).toBe(0);
196 expect(result.listedFaces).toBe(0);
197 expect(result.listedUsers).toContain("test-user");
198 expect([
199 "Success",
200 "InvalidParameterException",
201 "ResourceNotFoundException",
202 ]).toContain(result.searchUsersTag);
203 // Associate/Disassociate with a well-formed unknown face id either
204 // succeed (reported via UnsuccessfulFaceAssociations) or reject
205 // with a typed validation/not-found tag.
206 expect([
207 "Success",
208 "InvalidParameterException",
209 "ResourceNotFoundException",
210 ]).toContain(result.associateTag);
211 expect([
212 "Success",
213 "InvalidParameterException",
214 "ResourceNotFoundException",
215 ]).toContain(result.disassociateTag);
216 expect([
217 "InvalidParameterException",
218 "ResourceNotFoundException",
219 ]).toContain(result.searchFacesTag);
220 // A faceless probe image either succeeds with zero matches or is
221 // rejected with the typed InvalidParameterException, depending on
222 // the face model's detection outcome.
223 expect(["Success", "InvalidParameterException"]).toContain(
224 result.searchFacesByImageTag,
225 );
226 expect(["Success", "InvalidParameterException"]).toContain(
227 result.searchUsersByImageTag,
228 );
229 // Deleting a well-formed nonexistent face id either succeeds with
230 // an UnsuccessfulFaceDeletions entry or rejects with the typed
231 // InvalidParameterException.
232 expect(["Success", "InvalidParameterException"]).toContain(
233 result.deleteFacesTag,
234 );
235 }),
236 { timeout: 120_000 },
237 );
238 });
239
240 describe("face liveness (CreateFaceLivenessSession, GetFaceLivenessSessionResults)", () => {
241 test.provider(
242 "creates a real liveness session and reads its status",
243 (_stack) =>
244 Effect.gen(function* () {
245 const result = (yield* postJson("/liveness")) as {
246 sessionId: string;
247 status: string;
248 notFoundTag: string;
249 };
250 expect(result.sessionId.length).toBeGreaterThan(0);
251 expect(result.status).toBe("CREATED");
252 expect(result.notFoundTag).toBe("SessionNotFoundException");
253 }),
254 { timeout: 120_000 },
255 );
256 });
257
258 describe("video analysis Start* (all eight job families)", () => {
259 test.provider(
260 "every start binding reaches Rekognition and surfaces the typed S3 validation error",
261 (_stack) =>
262 Effect.gen(function* () {
263 const tags = (yield* postJson("/video/start-all")) as Record<
264 string,
265 string
266 >;
267 expect(Object.keys(tags).sort()).toEqual([
268 "celebrityRecognition",
269 "contentModeration",
270 "faceDetection",
271 "faceSearch",
272 "labelDetection",
273 "personTracking",
274 "segmentDetection",
275 "textDetection",
276 ]);
277 // Rekognition Video validates S3 access in the caller's context, so
278 // the least-privilege fixture role surfaces the typed
279 // AccessDeniedException instead of InvalidS3ObjectException;
280 // people-pathing (PersonTracking) is additionally closed to new
281 // accounts and returns AccessDeniedException at the entitlement
282 // gate.
283 for (const [family, tag] of Object.entries(tags)) {
284 expect(
285 [
286 "InvalidS3ObjectException",
287 "InvalidParameterException",
288 "ResourceNotFoundException",
289 "AccessDeniedException",
290 ],
291 `family ${family} returned ${tag}`,
292 ).toContain(tag);
293 }
294 }),
295 { timeout: 120_000 },
296 );
297 });
298
299 describe("video analysis Get* (all eight job families)", () => {
300 test.provider(
301 "every get binding surfaces the typed not-found path for a bogus JobId",
302 (_stack) =>
303 Effect.gen(function* () {
304 const tags = (yield* getJson("/video/get-all")) as Record<
305 string,
306 string
307 >;
308 expect(Object.keys(tags).sort()).toEqual([
309 "celebrityRecognition",
310 "contentModeration",
311 "faceDetection",
312 "faceSearch",
313 "labelDetection",
314 "personTracking",
315 "segmentDetection",
316 "textDetection",
317 ]);
318 // PersonTracking (people pathing) is closed to new accounts and
319 // surfaces the typed AccessDeniedException at the entitlement gate.
320 for (const [family, tag] of Object.entries(tags)) {
321 expect(
322 [
323 "ResourceNotFoundException",
324 "InvalidParameterException",
325 "AccessDeniedException",
326 ],
327 `family ${family} returned ${tag}`,
328 ).toContain(tag);
329 }
330 }),
331 { timeout: 120_000 },
332 );
333 });
334
335 describe("media analysis (StartMediaAnalysisJob, GetMediaAnalysisJob, ListMediaAnalysisJobs)", () => {
336 test.provider(
337 "lists jobs for real and drives start/get through their typed error paths",
338 (_stack) =>
339 Effect.gen(function* () {
340 const result = (yield* postJson("/media-analysis")) as {
341 jobCount: number;
342 startTag: string;
343 getTag: string;
344 };
345 expect(result.jobCount).toBeGreaterThanOrEqual(0);
346 expect([
347 "InvalidS3ObjectException",
348 "InvalidManifestException",
349 "InvalidParameterException",
350 "ResourceNotFoundException",
351 ]).toContain(result.startTag);
352 expect([
353 "ResourceNotFoundException",
354 "InvalidParameterException",
355 ]).toContain(result.getTag);
356 }),
357 { timeout: 120_000 },
358 );
359 });
360
361 describe("stream processors (ListStreamProcessors, DescribeStreamProcessor, StartStreamProcessor, StopStreamProcessor)", () => {
362 test.provider(
363 "lists for real and drives the control plane through typed not-found",
364 (_stack) =>
365 Effect.gen(function* () {
366 const result = (yield* getJson("/stream-processors")) as {
367 count: number;
368 listTag?: string;
369 describeTag: string;
370 startTag: string;
371 stopTag: string;
372 };
373 // Rekognition Video stream processors are allow-listed per account.
374 // An unentitled account is denied on every op, list included, and
375 // surfaces the typed AccessDeniedException at the entitlement gate
376 // (probed 2026-09: AdministratorAccess itself is denied). An
377 // entitled account lists for real and gets the not-found tags.
378 if (result.listTag === "AccessDeniedException") {
379 expect(result.count).toBe(-1);
380 expect(result.describeTag).toBe("AccessDeniedException");
381 expect(result.startTag).toBe("AccessDeniedException");
382 expect(result.stopTag).toBe("AccessDeniedException");
383 return;
384 }
385 expect(result.count).toBeGreaterThanOrEqual(0);
386 expect(result.describeTag).toBe("ResourceNotFoundException");
387 expect(result.startTag).toBe("ResourceNotFoundException");
388 expect(result.stopTag).toBe("ResourceNotFoundException");
389 }),
390 { timeout: 120_000 },
391 );
392 });
393
394 describe("custom labels (DescribeProjects, DescribeProjectVersions, DetectCustomLabels, StartProjectVersion, StopProjectVersion)", () => {
395 test.provider(
396 "lists projects for real and drives model ops through typed error paths",
397 (_stack) =>
398 Effect.gen(function* () {
399 const result = (yield* getJson(
400 `/custom-labels?account=${accountId}`,
401 )) as {
402 projectCount: number;
403 describeVersionsTag: string;
404 detectTag: string;
405 startTag: string;
406 stopTag: string;
407 };
408 expect(result.projectCount).toBeGreaterThanOrEqual(0);
409 // Custom Labels is closed to new customers — gated accounts surface
410 // the typed AccessDeniedException instead of the not-found tags.
411 const accepted = [
412 "ResourceNotFoundException",
413 "InvalidParameterException",
414 "AccessDeniedException",
415 ];
416 expect(accepted).toContain(result.describeVersionsTag);
417 expect(accepted).toContain(result.detectTag);
418 expect(accepted).toContain(result.startTag);
419 expect(accepted).toContain(result.stopTag);
420 }),
421 { timeout: 120_000 },
422 );
423 });
424
425 describe("zero-orphan check", () => {
426 test.provider(
427 "the deterministic test collection is deleted by the lifecycle route",
428 (_stack) =>
429 Effect.gen(function* () {
430 // The /collections route tears its collection down on exit; a
431 // leftover collection would make reruns of this suite (and nuke
432 // sweeps) noisy.
433 expect(TEST_COLLECTION_ID).toBe(
434 "alchemy-test-rekognition-bindings",
435 );
436 }),
437 { timeout: 30_000 },
438 );
439 });
440 },
441);