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 LakeFormationTestFunctionLive, {
11 LakeFormationTestFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "LakeFormationBindings");
17
18// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
19// over 60s on a fresh deploy.
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// Retry only 5xx (cold re-init, IAM propagation); a genuine 4xx/assertion
33// 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 "LakeFormation Bindings",
63 {
64 tags: [
65 "provider:aws",
66 "provider:aws:glue",
67 "provider:aws:lakeformation",
68 "provider:aws:lambda",
69 "live",
70 ],
71 },
72 () => {
73 beforeAll(
74 Effect.gen(function* () {
75 yield* Effect.logInfo(
76 "LakeFormation test setup: destroying previous resources",
77 );
78 yield* sharedStack.destroy();
79
80 yield* Effect.logInfo("LakeFormation test setup: deploying fixture");
81 const { functionUrl } = yield* sharedStack.deploy(
82 Effect.gen(function* () {
83 return yield* LakeFormationTestFunction;
84 }).pipe(Effect.provide(LakeFormationTestFunctionLive)),
85 );
86
87 expect(functionUrl).toBeTruthy();
88 baseUrl = functionUrl!.replace(/\/+$/, "");
89
90 const readinessUrl = `${baseUrl}/bindings`;
91 yield* HttpClient.get(readinessUrl).pipe(
92 Effect.flatMap((response) =>
93 response.status === 200
94 ? Effect.succeed(response)
95 : Effect.fail(
96 new Error(`Function not ready: ${response.status}`),
97 ),
98 ),
99 Effect.tapError((error) =>
100 Effect.logWarning(
101 `LakeFormation test setup: fixture not ready yet (${String(error)})`,
102 ),
103 ),
104 Effect.retry({ schedule: readinessPolicy }),
105 );
106 }),
107 { timeout: 240_000 },
108 );
109
110 afterAll(sharedStack.destroy(), { timeout: 120_000 });
111
112 describe("binding registration", () => {
113 test.provider("all 11 capabilities initialize in the runtime", (_stack) =>
114 Effect.gen(function* () {
115 const response = yield* getJson("/bindings");
116 expect((response as any).bound).toHaveLength(11);
117 }),
118 );
119 });
120
121 describe("GetDataLakePrincipal", () => {
122 test.provider(
123 "returns the calling principal",
124 (_stack) =>
125 Effect.gen(function* () {
126 const response = (yield* getJson("/principal")) as any;
127 expect(response.tag).toBe("Ok");
128 // the identity is the Lambda's assumed-role session
129 expect(response.identity).toBeTruthy();
130 }),
131 { timeout: 60_000 },
132 );
133 });
134
135 describe("ListLFTags", () => {
136 test.provider(
137 "lists visible tag definitions",
138 (_stack) =>
139 Effect.gen(function* () {
140 const response = (yield* getJson("/tags")) as any;
141 expect(response.tag).toBe("Ok");
142 expect(response.count).toBeGreaterThanOrEqual(0);
143 }),
144 { timeout: 60_000 },
145 );
146 });
147
148 describe("GetLFTag", () => {
149 test.provider(
150 "typed error for a missing tag key",
151 (_stack) =>
152 Effect.gen(function* () {
153 const response = (yield* getJson("/tag-missing")) as any;
154 expect([
155 "EntityNotFoundException",
156 "AccessDeniedException",
157 ]).toContain(response.tag);
158 }),
159 { timeout: 60_000 },
160 );
161 });
162
163 describe("ListPermissions", () => {
164 test.provider(
165 "lists grants visible to the caller",
166 (_stack) =>
167 Effect.gen(function* () {
168 const response = (yield* getJson("/permissions")) as any;
169 expect(response.tag).toBe("Ok");
170 expect(response.count).toBeGreaterThanOrEqual(0);
171 }),
172 { timeout: 60_000 },
173 );
174 });
175
176 describe("SearchDatabasesByLFTags", () => {
177 test.provider(
178 "typed rejection for a nonexistent tag key",
179 (_stack) =>
180 Effect.gen(function* () {
181 const response = (yield* getJson("/search-databases")) as any;
182 expect([
183 "Ok",
184 "EntityNotFoundException",
185 "AccessDeniedException",
186 ]).toContain(response.tag);
187 expect(response.count).toBe(0);
188 }),
189 { timeout: 60_000 },
190 );
191 });
192
193 describe("SearchTablesByLFTags", () => {
194 test.provider(
195 "typed rejection for a nonexistent tag key",
196 (_stack) =>
197 Effect.gen(function* () {
198 const response = (yield* getJson("/search-tables")) as any;
199 expect([
200 "Ok",
201 "EntityNotFoundException",
202 "AccessDeniedException",
203 ]).toContain(response.tag);
204 expect(response.count).toBe(0);
205 }),
206 { timeout: 60_000 },
207 );
208 });
209
210 describe("GetResourceLFTags", () => {
211 test.provider(
212 "reads the fixture database's LF-tags",
213 (_stack) =>
214 Effect.gen(function* () {
215 const response = (yield* getJson("/resource-tags")) as any;
216 // Ok (no tags assigned) under the default catalog settings; a
217 // Lake-Formation-locked catalog surfaces the typed denial instead.
218 expect([
219 "Ok",
220 "AccessDeniedException",
221 "EntityNotFoundException",
222 ]).toContain(response.tag);
223 }),
224 { timeout: 60_000 },
225 );
226 });
227
228 describe("GetEffectivePermissionsForPath", () => {
229 test.provider(
230 "empty permissions (or typed rejection) for an unregistered path",
231 (_stack) =>
232 Effect.gen(function* () {
233 const response = (yield* getJson("/effective-permissions")) as any;
234 // verified live: an unregistered path returns an empty permission
235 // list rather than EntityNotFound
236 expect([
237 "Ok",
238 "EntityNotFoundException",
239 "InvalidInputException",
240 ]).toContain(response.tag);
241 expect(response.count).toBe(0);
242 }),
243 { timeout: 60_000 },
244 );
245 });
246
247 describe("GetTemporaryGlueTableCredentials", () => {
248 test.provider(
249 "typed rejection proves lakeformation:GetDataAccess reaches the API",
250 (_stack) =>
251 Effect.gen(function* () {
252 const response = (yield* getJson("/table-credentials")) as any;
253 expect([
254 "AccessDeniedException",
255 "EntityNotFoundException",
256 "InvalidInputException",
257 "PermissionTypeMismatchException",
258 ]).toContain(response.tag);
259 }),
260 { timeout: 60_000 },
261 );
262 });
263
264 describe("GetTemporaryGluePartitionCredentials", () => {
265 test.provider(
266 "typed rejection proves lakeformation:GetDataAccess reaches the API",
267 (_stack) =>
268 Effect.gen(function* () {
269 const response = (yield* getJson("/partition-credentials")) as any;
270 expect([
271 "AccessDeniedException",
272 "EntityNotFoundException",
273 "InvalidInputException",
274 "PermissionTypeMismatchException",
275 ]).toContain(response.tag);
276 }),
277 { timeout: 60_000 },
278 );
279 });
280
281 describe("GetTemporaryDataLocationCredentials", () => {
282 test.provider(
283 "typed rejection for an unregistered location",
284 (_stack) =>
285 Effect.gen(function* () {
286 const response = (yield* getJson("/location-credentials")) as any;
287 expect([
288 "AccessDeniedException",
289 "EntityNotFoundException",
290 "InvalidInputException",
291 "ConflictException",
292 ]).toContain(response.tag);
293 }),
294 { timeout: 60_000 },
295 );
296 });
297 },
298);