1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as datazone from "@distilled.cloud/aws/datazone";
5import * as eventbridge from "@distilled.cloud/aws/eventbridge";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14import DataZoneTestFunctionLive, {
15 BINDINGS_DOMAIN_NAME,
16 BINDINGS_PROJECT_NAME,
17 DataZoneTestFunction,
18} from "./handler";
19
20const testOptions = { providers: AWS.providers() };
21const { test, beforeAll, afterAll } = Test.make(testOptions);
22const sharedStack = Core.scratchStack(testOptions, "DataZoneBindings");
23
24// Lambda function URL cold-start (DNS, IAM propagation, init) plus DataZone
25// user-profile propagation can take well over 60s on a fresh deploy.
26const readinessPolicy = Schedule.max([
27 Schedule.fixed("2 seconds"),
28 Schedule.recurs(75),
29]);
30
31let baseUrl: string;
32let functionArn: string;
33let functionRoleArn: string;
34
35class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
36 readonly status: number;
37 readonly body: string;
38}> {}
39
40// Retry only 5xx (cold re-init, IAM/user-profile propagation the handler's
41// `Effect.orDie` surfaces as a 500); a genuine 4xx surfaces immediately.
42const send = (request: HttpClientRequest.HttpClientRequest) =>
43 HttpClient.execute(request).pipe(
44 Effect.flatMap((response) =>
45 response.status >= 500
46 ? response.text.pipe(
47 Effect.flatMap((body) =>
48 Effect.fail(
49 new TransientUpstream({ status: response.status, body }),
50 ),
51 ),
52 )
53 : Effect.succeed(response),
54 ),
55 Effect.retry({
56 while: (e) => e._tag === "TransientUpstream",
57 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(8)]),
58 }),
59 );
60
61const getJson = (path: string) =>
62 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
63 Effect.flatMap((r) => r.json),
64 );
65
66/** Resolve the fixture domain's id by its deterministic name. */
67const findDomainId = Effect.gen(function* () {
68 const pages = yield* datazone.listDomains.pages({}).pipe(Stream.runCollect);
69 const summary = Array.from(pages)
70 .flatMap((page) => page.items ?? [])
71 .find(
72 (s) =>
73 (Redacted.isRedacted(s.name) ? Redacted.value(s.name) : s.name) ===
74 BINDINGS_DOMAIN_NAME &&
75 s.status !== "DELETING" &&
76 s.status !== "DELETED",
77 );
78 return summary?.id;
79});
80
81describe.sequential(
82 "DataZone Bindings",
83 {
84 tags: [
85 "provider:aws",
86 "provider:aws:datazone",
87 "provider:aws:lambda",
88 "live",
89 ],
90 },
91 () => {
92 beforeAll(
93 Effect.gen(function* () {
94 yield* Effect.logInfo(
95 "DataZone test setup: destroying previous resources",
96 );
97 yield* sharedStack.destroy();
98
99 yield* Effect.logInfo("DataZone test setup: deploying fixture");
100 const attrs = yield* sharedStack.deploy(
101 Effect.gen(function* () {
102 return yield* DataZoneTestFunction;
103 }).pipe(Effect.provide(DataZoneTestFunctionLive)),
104 );
105
106 expect(attrs.functionUrl).toBeTruthy();
107 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
108 functionArn = attrs.functionArn;
109 functionRoleArn = attrs.roleArn;
110
111 const readinessUrl = `${baseUrl}/bindings`;
112 yield* Effect.logInfo(
113 `DataZone test setup: probing readiness at ${readinessUrl}`,
114 );
115 yield* HttpClient.get(readinessUrl).pipe(
116 Effect.flatMap((response) =>
117 response.status === 200
118 ? Effect.succeed(response)
119 : Effect.fail(
120 new Error(`Function not ready: ${response.status}`),
121 ),
122 ),
123 Effect.tapError((error) =>
124 Effect.logWarning(
125 `DataZone test setup: fixture not ready yet (${String(error)})`,
126 ),
127 ),
128 Effect.retry({ schedule: readinessPolicy }),
129 );
130 }),
131 { timeout: 600_000 },
132 );
133
134 afterAll(sharedStack.destroy(), { timeout: 600_000 });
135
136 describe("user profile setup", () => {
137 // DataZone authorizes data-plane calls against a *user profile*, not
138 // just IAM — create one for the function's execution role before any
139 // route test calls the API through the bindings. Runs as the first
140 // sequential test (distilled calls need the provider credentials that
141 // `test.provider` supplies).
142 test.provider("creates a user profile for the function role", () =>
143 Effect.gen(function* () {
144 const domainId = yield* findDomainId;
145 expect(domainId).toBeTruthy();
146 const profile = yield* datazone
147 .createUserProfile({
148 domainIdentifier: domainId!,
149 userIdentifier: functionRoleArn,
150 userType: "IAM_ROLE",
151 })
152 .pipe(
153 // already exists from a previous run — resolve it instead.
154 Effect.catchTag("ValidationException", () =>
155 datazone.getUserProfile({
156 domainIdentifier: domainId!,
157 userIdentifier: functionRoleArn,
158 type: "IAM",
159 }),
160 ),
161 );
162 expect(profile.id).toBeTruthy();
163
164 // Inventory search is project-scoped: the function's user profile
165 // must be a member of the fixture project.
166 const projects = yield* datazone.listProjects({
167 domainIdentifier: domainId!,
168 });
169 const project = (projects.items ?? []).find(
170 (p) =>
171 (Redacted.isRedacted(p.name)
172 ? Redacted.value(p.name)
173 : p.name) === BINDINGS_PROJECT_NAME,
174 );
175 expect(project).toBeTruthy();
176 yield* datazone
177 .createProjectMembership({
178 domainIdentifier: domainId!,
179 projectIdentifier: project!.id,
180 member: { userIdentifier: profile.id! },
181 designation: "PROJECT_CONTRIBUTOR",
182 })
183 .pipe(
184 // already a member from a previous run
185 Effect.catchTag("ValidationException", () => Effect.void),
186 );
187 }),
188 );
189 });
190
191 describe("binding registration", () => {
192 test.provider(
193 "all 30 domain capabilities initialize in the runtime",
194 () =>
195 Effect.gen(function* () {
196 const response = (yield* getJson("/bindings")) as {
197 bound: string[];
198 };
199 expect(response.bound).toHaveLength(30);
200 expect(response.bound).toContain("search");
201 expect(response.bound).toContain("acceptSubscriptionRequest");
202 expect(response.bound).toContain("postLineageEvent");
203 }),
204 );
205 });
206
207 describe("Search", () => {
208 test.provider(
209 "searches the domain inventory (injected domain id)",
210 () =>
211 Effect.gen(function* () {
212 const response = (yield* getJson("/search")) as {
213 ok: boolean;
214 totalMatchCount: number;
215 items: number;
216 error?: string;
217 };
218 // fresh domain — no assets, but the call must succeed end-to-end.
219 expect(response).toMatchObject({ ok: true, items: 0 });
220 }),
221 { timeout: 240_000 },
222 );
223 });
224
225 describe("SearchListings", () => {
226 test.provider("searches the published catalog", () =>
227 Effect.gen(function* () {
228 const response = (yield* getJson("/listings")) as {
229 ok: boolean;
230 items: number;
231 };
232 expect(response).toMatchObject({ ok: true, items: 0 });
233 }),
234 );
235 });
236
237 describe("ListSubscriptions", () => {
238 test.provider(
239 "lists approved subscriptions (empty in a fresh domain)",
240 () =>
241 Effect.gen(function* () {
242 const response = (yield* getJson("/subscriptions")) as {
243 ok: boolean;
244 items: number;
245 };
246 expect(response).toMatchObject({ ok: true, items: 0 });
247 }),
248 );
249 });
250
251 describe("ListSubscriptionRequests", () => {
252 test.provider("lists pending requests (empty in a fresh domain)", () =>
253 Effect.gen(function* () {
254 const response = (yield* getJson("/requests")) as {
255 ok: boolean;
256 items: number;
257 };
258 expect(response).toMatchObject({ ok: true, items: 0 });
259 }),
260 );
261 });
262
263 describe("GetIamPortalLoginUrl", () => {
264 test.provider("mints a data portal sign-in URL", () =>
265 Effect.gen(function* () {
266 const response = (yield* getJson("/portal")) as {
267 ok: boolean;
268 authCodeUrl?: string;
269 userProfileId?: string;
270 };
271 expect(response.ok).toBe(true);
272 expect(response.authCodeUrl).toBeTruthy();
273 }),
274 );
275 });
276
277 describe(
278 "consumeDataZoneEvents",
279 { tags: ["provider:aws:eventbridge"] },
280 () => {
281 test.provider(
282 "the deploy created an EventBridge rule targeting the function",
283 () =>
284 Effect.gen(function* () {
285 // Out-of-band via distilled: the fixture's consumeDataZoneEvents
286 // must have materialized as a rule on the default bus with the
287 // Lambda as target.
288 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
289 TargetArn: functionArn,
290 });
291 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
292 }),
293 );
294 },
295 );
296 },
297);