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 RAMTestFunctionLive, { RAMTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "RAMBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// The shared Lambda fixture occasionally answers a transient 5xx under load
31// (cold re-init, IAM propagation on the freshly attached policy that the
32// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
33// 4xx/assertion failure surfaces immediately.
34const send = (request: HttpClientRequest.HttpClientRequest) =>
35 HttpClient.execute(request).pipe(
36 Effect.flatMap((response) =>
37 response.status >= 500
38 ? response.text.pipe(
39 Effect.flatMap((body) =>
40 Effect.fail(
41 new TransientUpstream({ status: response.status, body }),
42 ),
43 ),
44 )
45 : Effect.succeed(response),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "TransientUpstream",
49 schedule: Schedule.max([
50 Schedule.exponential("500 millis"),
51 Schedule.recurs(6),
52 ]),
53 }),
54 );
55
56const getJson = (path: string) =>
57 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
58 Effect.flatMap((r) => r.json),
59 );
60
61describe.sequential(
62 "RAM Bindings",
63 { tags: ["provider:aws", "provider:aws:lambda", "provider:aws:ram", "live"] },
64 () => {
65 beforeAll(
66 Effect.gen(function* () {
67 yield* Effect.logInfo("RAM test setup: destroying previous resources");
68 yield* sharedStack.destroy();
69
70 yield* Effect.logInfo("RAM test setup: deploying fixture");
71 const { functionUrl } = yield* sharedStack.deploy(
72 Effect.gen(function* () {
73 return yield* RAMTestFunction;
74 }).pipe(Effect.provide(RAMTestFunctionLive)),
75 );
76
77 expect(functionUrl).toBeTruthy();
78 baseUrl = functionUrl!.replace(/\/+$/, "");
79
80 const readinessUrl = `${baseUrl}/bindings`;
81 yield* Effect.logInfo(
82 `RAM test setup: probing readiness at ${readinessUrl}`,
83 );
84 yield* HttpClient.get(readinessUrl).pipe(
85 Effect.flatMap((response) =>
86 response.status === 200
87 ? Effect.succeed(response)
88 : Effect.fail(
89 new Error(`Function not ready: ${response.status}`),
90 ),
91 ),
92 Effect.tapError((error) =>
93 Effect.logWarning(
94 `RAM test setup: fixture not ready yet (${String(error)})`,
95 ),
96 ),
97 Effect.retry({ schedule: readinessPolicy }),
98 );
99 }),
100 { timeout: 240_000 },
101 );
102
103 afterAll(sharedStack.destroy(), { timeout: 120_000 });
104
105 describe("binding registration", () => {
106 test.provider("all 11 capabilities initialize in the runtime", (_stack) =>
107 Effect.gen(function* () {
108 const response = yield* getJson("/bindings");
109 expect((response as any).bound).toHaveLength(11);
110 }),
111 );
112 });
113
114 describe("GetResourceShares", () => {
115 test.provider(
116 "lists the account's shares including the fixture share",
117 (_stack) =>
118 Effect.gen(function* () {
119 const response = (yield* getJson("/shares")) as any;
120 expect(response.count).toBeGreaterThan(0);
121 }),
122 { timeout: 60_000 },
123 );
124 });
125
126 describe("GetResourceShareAssociations", () => {
127 test.provider(
128 "lists the fixture share's principal associations",
129 (_stack) =>
130 Effect.gen(function* () {
131 const response = (yield* getJson("/associations")) as any;
132 expect(response.tag).toBe("Ok");
133 expect(response.count).toBeGreaterThanOrEqual(0);
134 }),
135 { timeout: 60_000 },
136 );
137 });
138
139 describe("GetResourceShareInvitations", () => {
140 test.provider(
141 "lists the invitations sent to this account",
142 (_stack) =>
143 Effect.gen(function* () {
144 const response = (yield* getJson("/invitations")) as any;
145 expect(response.count).toBeGreaterThanOrEqual(0);
146 }),
147 { timeout: 60_000 },
148 );
149 });
150
151 describe("AcceptResourceShareInvitation", () => {
152 test.provider(
153 "surfaces the typed not-found for a nonexistent invitation (proving the grant)",
154 (_stack) =>
155 Effect.gen(function* () {
156 const response = (yield* getJson("/accept-nonexistent")) as any;
157 expect([
158 "ResourceShareInvitationArnNotFoundException",
159 "MalformedArnException",
160 ]).toContain(response.tag);
161 }),
162 { timeout: 60_000 },
163 );
164 });
165
166 describe("RejectResourceShareInvitation", () => {
167 test.provider(
168 "surfaces the typed not-found for a nonexistent invitation (proving the grant)",
169 (_stack) =>
170 Effect.gen(function* () {
171 const response = (yield* getJson("/reject-nonexistent")) as any;
172 expect([
173 "ResourceShareInvitationArnNotFoundException",
174 "MalformedArnException",
175 ]).toContain(response.tag);
176 }),
177 { timeout: 60_000 },
178 );
179 });
180
181 describe("ListPendingInvitationResources", () => {
182 test.provider(
183 "surfaces the typed not-found for a nonexistent invitation (proving the grant)",
184 (_stack) =>
185 Effect.gen(function* () {
186 const response = (yield* getJson(
187 "/pending-resources-nonexistent",
188 )) as any;
189 expect([
190 "ResourceShareInvitationArnNotFoundException",
191 "MalformedArnException",
192 "InvalidParameterException",
193 ]).toContain(response.tag);
194 }),
195 { timeout: 60_000 },
196 );
197 });
198
199 describe("ListResources", () => {
200 test.provider(
201 "lists the resources this account shares",
202 (_stack) =>
203 Effect.gen(function* () {
204 const response = (yield* getJson("/resources")) as any;
205 expect(response.count).toBeGreaterThanOrEqual(0);
206 }),
207 { timeout: 60_000 },
208 );
209 });
210
211 describe("ListPrincipals", () => {
212 test.provider(
213 "lists the principals this account shares with",
214 (_stack) =>
215 Effect.gen(function* () {
216 const response = (yield* getJson("/principals")) as any;
217 expect(response.count).toBeGreaterThanOrEqual(0);
218 }),
219 { timeout: 60_000 },
220 );
221 });
222
223 describe("GetResourcePolicies", () => {
224 test.provider(
225 "answers for a nonexistent resource (typed not-found or empty, proving the grant)",
226 (_stack) =>
227 Effect.gen(function* () {
228 const response = (yield* getJson(
229 "/resource-policies-nonexistent",
230 )) as any;
231 expect([
232 "Ok",
233 "ResourceArnNotFoundException",
234 "MalformedArnException",
235 ]).toContain(response.tag);
236 }),
237 { timeout: 60_000 },
238 );
239 });
240
241 describe("ListPermissions", () => {
242 test.provider(
243 "lists the AWS managed permissions",
244 (_stack) =>
245 Effect.gen(function* () {
246 const response = (yield* getJson("/permissions")) as any;
247 // Every account sees the AWS managed permissions.
248 expect(response.count).toBeGreaterThan(0);
249 }),
250 { timeout: 60_000 },
251 );
252 });
253
254 describe("GetPermission", () => {
255 test.provider(
256 "fetches the first listed permission",
257 (_stack) =>
258 Effect.gen(function* () {
259 const response = (yield* getJson("/permission")) as any;
260 expect(response.tag).toBe("Ok");
261 expect(typeof response.name).toBe("string");
262 }),
263 { timeout: 60_000 },
264 );
265 });
266 },
267);