1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as securitylake from "@distilled.cloud/aws/securitylake";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Result from "effect/Result";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import SecurityLakeBindingsFunctionLive, {
11 SecurityLakeBindingsFunction,
12} from "./bindings-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16
17// Enabling Security Lake onboards the whole account in the Region (S3
18// buckets, Lake Formation, Glue metastore), so the live Lambda E2E is gated
19// behind AWS_TEST_SECURITYLAKE=1 like the lifecycle test.
20const RUN_LIVE = !!process.env.AWS_TEST_SECURITYLAKE;
21
22// Ungated typed-error probes: prove the distilled error unions the bindings
23// depend on are typed on every account, onboarded or not, at near-zero cost.
24test.provider(
25 "listDataLakeExceptions returns exceptions or a typed not-onboarded rejection",
26 () =>
27 Effect.gen(function* () {
28 const result = yield* Effect.result(
29 securitylake.listDataLakeExceptions({}),
30 );
31 if (Result.isSuccess(result)) {
32 expect(Array.isArray(result.success.exceptions ?? [])).toBe(true);
33 } else {
34 expect([
35 "AccessDeniedException",
36 "ResourceNotFoundException",
37 "UnauthorizedException",
38 ]).toContain(result.failure._tag);
39 }
40 }),
41 { tags: ["provider:aws", "provider:aws:securitylake", "live"] },
42);
43
44test.provider(
45 "getDataLakeSources returns sources or a typed not-onboarded rejection",
46 () =>
47 Effect.gen(function* () {
48 const result = yield* Effect.result(securitylake.getDataLakeSources({}));
49 if (Result.isSuccess(result)) {
50 expect(Array.isArray(result.success.dataLakeSources ?? [])).toBe(true);
51 } else {
52 expect([
53 "AccessDeniedException",
54 "ResourceNotFoundException",
55 "UnauthorizedException",
56 ]).toContain(result.failure._tag);
57 }
58 }),
59 { tags: ["provider:aws", "provider:aws:securitylake", "live"] },
60);
61
62const sharedStack = Core.scratchStack(testOptions, "SecurityLakeBindings");
63
64let baseUrl: string;
65
66const get = (path: string) =>
67 HttpClient.get(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
68
69describe(
70 "SecurityLake Bindings (E2E)",
71 {
72 tags: [
73 "provider:aws",
74 "provider:aws:iam",
75 "provider:aws:lambda",
76 "provider:aws:securitylake",
77 "live",
78 ],
79 },
80 () => {
81 beforeAll(
82 Effect.gen(function* () {
83 if (!RUN_LIVE) return;
84 yield* Effect.logInfo(
85 "SecurityLake E2E setup: destroying previous run",
86 );
87 yield* sharedStack.destroy();
88
89 yield* Effect.logInfo(
90 "SecurityLake E2E setup: deploying data lake + Lambda",
91 );
92 const { functionUrl } = yield* sharedStack.deploy(
93 Effect.gen(function* () {
94 return yield* SecurityLakeBindingsFunction;
95 }).pipe(Effect.provide(SecurityLakeBindingsFunctionLive)),
96 );
97
98 expect(functionUrl).toBeTruthy();
99 baseUrl = functionUrl!.replace(/\/+$/, "");
100
101 // Readiness probe — fresh function URLs take seconds to serve 200s.
102 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
103 Effect.flatMap((response) =>
104 response.status === 200
105 ? Effect.succeed(response)
106 : Effect.fail(
107 new Error(`Function not ready: ${response.status}`),
108 ),
109 ),
110 Effect.retry({
111 schedule: Schedule.max([
112 Schedule.fixed("2 seconds"),
113 Schedule.recurs(60),
114 ]),
115 }),
116 );
117 }),
118 { timeout: 600_000 },
119 );
120 afterAll(
121 Effect.gen(function* () {
122 if (!RUN_LIVE) return;
123 yield* sharedStack.destroy();
124 }),
125 { timeout: 600_000 },
126 );
127
128 test.provider.skipIf(!RUN_LIVE)(
129 "both capabilities initialize in the runtime",
130 () =>
131 Effect.gen(function* () {
132 const response = (yield* get("/bindings")) as { bound: string[] };
133 expect(response.bound).toHaveLength(2);
134 }),
135 );
136
137 test.provider.skipIf(!RUN_LIVE)(
138 "ListDataLakeExceptions grant + call round-trips from the Lambda",
139 () =>
140 Effect.gen(function* () {
141 const response = (yield* get("/exceptions")) as { count: number };
142 expect(response.count).toBeGreaterThanOrEqual(0);
143 }),
144 );
145
146 test.provider.skipIf(!RUN_LIVE)(
147 "GetDataLakeSources grant + call round-trips from the Lambda",
148 () =>
149 Effect.gen(function* () {
150 const response = (yield* get("/sources")) as {
151 dataLakeArn: string | undefined;
152 count: number;
153 };
154 expect(response.dataLakeArn).toContain(":securitylake:");
155 expect(response.count).toBeGreaterThanOrEqual(0);
156 }),
157 );
158 },
159);