1import * as KinesisAnalyticsV2 from "@/AWS/KinesisAnalyticsV2";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12/**
13 * Deterministic out-of-band code bucket, provisioned by the test's
14 * `beforeAll` (see code-bucket.ts) BEFORE the stack deploys — the service
15 * reads the code object at application-create time.
16 */
17export const FIXTURE_CODE_BUCKET = "alchemy-test-kav2-bindings-code";
18/** Must match `codeKey` in code-bucket.ts. */
19const FIXTURE_CODE_KEY = "code/app.zip";
20
21export class KinesisAnalyticsV2TestFunction extends Lambda.Function<Lambda.Function>()(
22 "KinesisAnalyticsV2TestFunction",
23) {}
24
25/**
26 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
27 * operation fails with a TYPED error — the test asserts the tag is a typed,
28 * non-authorization tag, which proves both the binding wiring and the IAM
29 * grant. An untyped error crashes into a 500 instead.
30 */
31const errorTagged = <A, E extends { _tag: string }, R>(
32 effect: Effect.Effect<A, E, R>,
33): Effect.Effect<A | { errorTag: string }, never, R> =>
34 effect.pipe(
35 Effect.map((a): A | { errorTag: string } => a),
36 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
37 );
38
39export default KinesisAnalyticsV2TestFunction.make(
40 {
41 main,
42 functionUrl: true,
43 // Application probes fan out SDK calls — AWS's 3s default intermittently
44 // times out under cold starts.
45 timeout: Duration.seconds(30),
46 },
47 Effect.gen(function* () {
48 // The application never starts (the staged zip is a placeholder, not a
49 // real Flink jar) — it stays READY, so every route below either reads
50 // harmlessly or drives a typed service-side rejection.
51 const app = yield* KinesisAnalyticsV2.Application("BindingsApp", {
52 runtimeEnvironment: "FLINK-1_20",
53 code: {
54 bucketArn: `arn:aws:s3:::${FIXTURE_CODE_BUCKET}`,
55 fileKey: FIXTURE_CODE_KEY,
56 },
57 snapshotsEnabled: true,
58 });
59
60 const describeApplication =
61 yield* KinesisAnalyticsV2.DescribeApplication(app);
62 const describeApplicationVersion =
63 yield* KinesisAnalyticsV2.DescribeApplicationVersion(app);
64 const listApplicationVersions =
65 yield* KinesisAnalyticsV2.ListApplicationVersions(app);
66 const describeApplicationOperation =
67 yield* KinesisAnalyticsV2.DescribeApplicationOperation(app);
68 const listApplicationOperations =
69 yield* KinesisAnalyticsV2.ListApplicationOperations(app);
70 const startApplication = yield* KinesisAnalyticsV2.StartApplication(app);
71 const stopApplication = yield* KinesisAnalyticsV2.StopApplication(app);
72 const rollbackApplication =
73 yield* KinesisAnalyticsV2.RollbackApplication(app);
74 const createApplicationSnapshot =
75 yield* KinesisAnalyticsV2.CreateApplicationSnapshot(app);
76 const describeApplicationSnapshot =
77 yield* KinesisAnalyticsV2.DescribeApplicationSnapshot(app);
78 const listApplicationSnapshots =
79 yield* KinesisAnalyticsV2.ListApplicationSnapshots(app);
80 const deleteApplicationSnapshot =
81 yield* KinesisAnalyticsV2.DeleteApplicationSnapshot(app);
82 const createApplicationPresignedUrl =
83 yield* KinesisAnalyticsV2.CreateApplicationPresignedUrl(app);
84 // Account-level binding — takes no resource argument.
85 const listApplications = yield* KinesisAnalyticsV2.ListApplications();
86
87 const bound = {
88 describeApplication,
89 describeApplicationVersion,
90 listApplicationVersions,
91 describeApplicationOperation,
92 listApplicationOperations,
93 startApplication,
94 stopApplication,
95 rollbackApplication,
96 createApplicationSnapshot,
97 describeApplicationSnapshot,
98 listApplicationSnapshots,
99 deleteApplicationSnapshot,
100 createApplicationPresignedUrl,
101 listApplications,
102 };
103
104 return {
105 fetch: Effect.gen(function* () {
106 const request = yield* HttpServerRequest;
107 const url = new URL(request.originalUrl);
108 const route = `${request.method} ${url.pathname}`;
109
110 switch (route) {
111 case "GET /bindings":
112 return yield* HttpServerResponse.json({
113 bound: Object.keys(bound),
114 });
115
116 case "GET /describe": {
117 const result = yield* errorTagged(describeApplication());
118 return yield* HttpServerResponse.json(
119 "errorTag" in result
120 ? result
121 : {
122 status: result.ApplicationDetail.ApplicationStatus,
123 runtime: result.ApplicationDetail.RuntimeEnvironment,
124 name: result.ApplicationDetail.ApplicationName,
125 },
126 );
127 }
128
129 case "GET /applications": {
130 // Self-verifying: the account list must contain the bound
131 // application (looked up by name via the describe binding).
132 const result = yield* errorTagged(
133 Effect.gen(function* () {
134 const { ApplicationDetail } = yield* describeApplication();
135 const listed = yield* listApplications({ Limit: 50 });
136 return {
137 count: (listed.ApplicationSummaries ?? []).length,
138 containsSelf: (listed.ApplicationSummaries ?? []).some(
139 (summary) =>
140 summary.ApplicationName ===
141 ApplicationDetail.ApplicationName,
142 ),
143 };
144 }),
145 );
146 return yield* HttpServerResponse.json(result);
147 }
148
149 case "GET /versions": {
150 const result = yield* errorTagged(listApplicationVersions());
151 return yield* HttpServerResponse.json(
152 "errorTag" in result
153 ? result
154 : { count: (result.ApplicationVersionSummaries ?? []).length },
155 );
156 }
157
158 case "GET /version": {
159 const result = yield* errorTagged(
160 describeApplicationVersion({ ApplicationVersionId: 1 }),
161 );
162 return yield* HttpServerResponse.json(
163 "errorTag" in result
164 ? result
165 : {
166 versionId:
167 result.ApplicationVersionDetail?.ApplicationVersionId,
168 },
169 );
170 }
171
172 case "GET /operations": {
173 const result = yield* errorTagged(listApplicationOperations());
174 return yield* HttpServerResponse.json(
175 "errorTag" in result
176 ? result
177 : { count: (result.ApplicationOperationInfoList ?? []).length },
178 );
179 }
180
181 case "GET /operation": {
182 // A well-formed-but-nonexistent operation id — an IAM gap would
183 // surface AccessDeniedException (an opaque 500) instead of the
184 // typed rejection.
185 const result = yield* errorTagged(
186 describeApplicationOperation({
187 OperationId: "aaaaaaaaaaaaaaaaaaaaaaaaaa",
188 }),
189 );
190 return yield* HttpServerResponse.json(
191 "errorTag" in result ? result : { ok: true },
192 );
193 }
194
195 case "GET /snapshots": {
196 const result = yield* errorTagged(listApplicationSnapshots());
197 return yield* HttpServerResponse.json(
198 "errorTag" in result
199 ? result
200 : { count: (result.SnapshotSummaries ?? []).length },
201 );
202 }
203
204 case "GET /snapshot": {
205 const result = yield* errorTagged(
206 describeApplicationSnapshot({ SnapshotName: "does-not-exist" }),
207 );
208 return yield* HttpServerResponse.json(
209 "errorTag" in result ? result : { ok: true },
210 );
211 }
212
213 case "POST /snapshot/create": {
214 // The application is READY (not RUNNING) — the service rejects
215 // the snapshot with a typed error, proving the grant without
216 // mutating anything.
217 const result = yield* errorTagged(
218 createApplicationSnapshot({ SnapshotName: "bindings-probe" }),
219 );
220 return yield* HttpServerResponse.json(
221 "errorTag" in result ? result : { ok: true },
222 );
223 }
224
225 case "POST /snapshot/delete": {
226 const result = yield* errorTagged(
227 deleteApplicationSnapshot({
228 SnapshotName: "does-not-exist",
229 SnapshotCreationTimestamp: new Date(0),
230 }),
231 );
232 return yield* HttpServerResponse.json(
233 "errorTag" in result ? result : { ok: true },
234 );
235 }
236
237 case "GET /presigned-url": {
238 const result = yield* errorTagged(
239 createApplicationPresignedUrl({
240 UrlType: "FLINK_DASHBOARD_URL",
241 SessionExpirationDurationInSeconds: 1800,
242 }),
243 );
244 return yield* HttpServerResponse.json(
245 "errorTag" in result
246 ? result
247 : { hasUrl: typeof result.AuthorizedUrl === "string" },
248 );
249 }
250
251 case "POST /rollback": {
252 // A READY application has nothing to roll back — typed rejection
253 // proves the grant.
254 const result = yield* errorTagged(
255 Effect.gen(function* () {
256 const { ApplicationDetail } = yield* describeApplication();
257 return yield* rollbackApplication({
258 CurrentApplicationVersionId:
259 ApplicationDetail.ApplicationVersionId,
260 });
261 }),
262 );
263 return yield* HttpServerResponse.json(
264 "errorTag" in result ? result : { ok: true },
265 );
266 }
267
268 case "POST /stop": {
269 // Force-stopping a READY application is a typed no-op rejection.
270 const result = yield* errorTagged(stopApplication({ Force: true }));
271 return yield* HttpServerResponse.json(
272 "errorTag" in result ? result : { ok: true },
273 );
274 }
275
276 case "POST /start": {
277 // Restoring from a nonexistent custom snapshot rejects before
278 // the placeholder zip ever runs. If the service accepts the
279 // start anyway, force-stop immediately so the fixture returns
280 // to READY for teardown.
281 const result = yield* errorTagged(
282 startApplication({
283 RunConfiguration: {
284 ApplicationRestoreConfiguration: {
285 ApplicationRestoreType: "RESTORE_FROM_CUSTOM_SNAPSHOT",
286 SnapshotName: "does-not-exist",
287 },
288 },
289 }),
290 );
291 if ("errorTag" in result) {
292 return yield* HttpServerResponse.json(result);
293 }
294 yield* errorTagged(stopApplication({ Force: true }));
295 return yield* HttpServerResponse.json({
296 started: true,
297 stopped: true,
298 });
299 }
300
301 default:
302 return yield* HttpServerResponse.json(
303 { error: "Not found", route },
304 { status: 404 },
305 );
306 }
307 }).pipe(Effect.orDie),
308 };
309 }).pipe(
310 Effect.provide(
311 Layer.mergeAll(
312 KinesisAnalyticsV2.DescribeApplicationHttp,
313 KinesisAnalyticsV2.DescribeApplicationVersionHttp,
314 KinesisAnalyticsV2.ListApplicationVersionsHttp,
315 KinesisAnalyticsV2.DescribeApplicationOperationHttp,
316 KinesisAnalyticsV2.ListApplicationOperationsHttp,
317 KinesisAnalyticsV2.StartApplicationHttp,
318 KinesisAnalyticsV2.StopApplicationHttp,
319 KinesisAnalyticsV2.RollbackApplicationHttp,
320 KinesisAnalyticsV2.CreateApplicationSnapshotHttp,
321 KinesisAnalyticsV2.DescribeApplicationSnapshotHttp,
322 KinesisAnalyticsV2.ListApplicationSnapshotsHttp,
323 KinesisAnalyticsV2.DeleteApplicationSnapshotHttp,
324 KinesisAnalyticsV2.CreateApplicationPresignedUrlHttp,
325 KinesisAnalyticsV2.ListApplicationsHttp,
326 ),
327 ),
328 ),
329);