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 OamTestFunctionLive, { OamTestFunction } from "./handler";
11import { makeOamTestLease } from "./TestLease.ts";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "OAMBindings");
16const serviceLease = makeOamTestLease();
17
18beforeAll(serviceLease.acquire, { timeout: 3_600_000 });
19afterAll(serviceLease.release);
20
21// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
22// over 60s on a fresh deploy.
23const readinessPolicy = Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(75),
26]);
27
28let baseUrl: string;
29
30class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
31 readonly status: number;
32 readonly body: string;
33}> {}
34
35// The Lambda fixture occasionally answers a transient 5xx under load (cold
36// re-init, IAM propagation on the freshly attached policy that the handler's
37// `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine 4xx/assertion
38// failure surfaces immediately.
39const send = (request: HttpClientRequest.HttpClientRequest) =>
40 HttpClient.execute(request).pipe(
41 Effect.flatMap((response) =>
42 response.status >= 500
43 ? response.text.pipe(
44 Effect.flatMap((body) =>
45 Effect.fail(
46 new TransientUpstream({ status: response.status, body }),
47 ),
48 ),
49 )
50 : Effect.succeed(response),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "TransientUpstream",
54 schedule: Schedule.max([
55 Schedule.exponential("500 millis"),
56 Schedule.recurs(6),
57 ]),
58 }),
59 );
60
61const getJson = (path: string) =>
62 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
63 Effect.flatMap((r) => r.json),
64 );
65
66// AWS enforces ONE OAM sink per account per region, so this file must never
67// overlap with Sink.test.ts (which deploys its own sink). The single-fork
68// suite run executes files sequentially and both files destroy their sink on
69// the way in and out.
70describe.sequential(
71 "OAM Bindings",
72 { tags: ["provider:aws", "provider:aws:lambda", "provider:aws:oam", "live"] },
73 () => {
74 beforeAll(
75 Effect.gen(function* () {
76 yield* Effect.logInfo("OAM test setup: destroying previous resources");
77 yield* sharedStack.destroy();
78
79 yield* Effect.logInfo("OAM test setup: deploying fixture");
80 const attrs = yield* sharedStack.deploy(
81 Effect.gen(function* () {
82 return yield* OamTestFunction;
83 }).pipe(Effect.provide(OamTestFunctionLive)),
84 );
85
86 expect(attrs.functionUrl).toBeTruthy();
87 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
88
89 const readinessUrl = `${baseUrl}/bindings`;
90 yield* Effect.logInfo(
91 `OAM test setup: probing readiness at ${readinessUrl}`,
92 );
93 yield* HttpClient.get(readinessUrl).pipe(
94 Effect.flatMap((response) =>
95 response.status === 200
96 ? Effect.succeed(response)
97 : Effect.fail(
98 new Error(`Function not ready: ${response.status}`),
99 ),
100 ),
101 Effect.tapError((error) =>
102 Effect.logWarning(
103 `OAM test setup: fixture not ready yet (${String(error)})`,
104 ),
105 ),
106 Effect.retry({ schedule: readinessPolicy }),
107 );
108 }),
109 { timeout: 240_000 },
110 );
111
112 afterAll(sharedStack.destroy(), { timeout: 120_000 });
113
114 describe("binding registration", () => {
115 test.provider("the capability initializes in the runtime", (_stack) =>
116 Effect.gen(function* () {
117 const response = (yield* getJson("/bindings")) as { bound: string[] };
118 expect(response.bound).toEqual(["listAttachedLinks"]);
119 }),
120 );
121 });
122
123 describe("ListAttachedLinks", () => {
124 test.provider(
125 "lists the bound sink's attached links (injected sink ARN)",
126 (_stack) =>
127 Effect.gen(function* () {
128 // Same-account links are rejected by OAM, so the fixture's sink has
129 // no attached links — an empty Items list proves both the
130 // oam:ListAttachedLinks grant on the sink ARN and the
131 // SinkIdentifier injection.
132 const response = (yield* getJson("/attached-links")) as {
133 count: number;
134 linkArns: string[];
135 };
136 expect(response.count).toBe(0);
137 expect(response.linkArns).toEqual([]);
138 }),
139 );
140 });
141 },
142);