1import * as AWS from "@/AWS";
2import * as Test from "./Test.ts";
3import * as ag from "@distilled.cloud/aws/api-gateway";
4import { expect } from "alchemy-test";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11
12import ApiGatewayBindingsFunctionLive, {
13 ApiGatewayBindingsFunction,
14} from "./fixtures/bindings-handler.ts";
15import { assertApiKeyDeleted } from "./assertions.ts";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19const keyName = "alchemy-ag-bindings-test-key";
20
21// The API key under test is created at RUNTIME through the deployed
22// Lambda's binding — it is not a stack resource, so `stack.destroy()`
23// can't clean it up if the test dies between `/keys` and `/key` DELETE.
24// Reap strays by their constant name (pre-clean + ensuring finalizer).
25const reapApiKeys = ag.getApiKeys.pages({ nameQuery: keyName }).pipe(
26 Stream.runCollect,
27 Effect.map((chunk) =>
28 Array.from(chunk).flatMap((page) =>
29 (page.items ?? []).filter(
30 (key): key is ag.ApiKey & { id: string } =>
31 key.id != null && key.name === keyName,
32 ),
33 ),
34 ),
35 Effect.flatMap(
36 Effect.forEach((key) =>
37 ag
38 .deleteApiKey({ apiKey: key.id })
39 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
40 ),
41 ),
42 Effect.asVoid,
43 // Finalizer contract: the error channel must be `never`.
44 Effect.orDie,
45);
46
47// Fresh Lambda function URLs return transient errors for the first
48// 30–90s (DNS, IAM propagation, cold init). Poll at a steady cadence.
49const readinessSchedule = Schedule.max([
50 Schedule.exponential(500).pipe(
51 Schedule.modifyDelay(({ duration: d }) =>
52 Effect.succeed(
53 Duration.isGreaterThan(d, Duration.seconds(10))
54 ? Duration.seconds(10)
55 : d,
56 ),
57 ),
58 ),
59 Schedule.recurs(20),
60]);
61
62const getJson = (url: string) =>
63 HttpClient.get(url).pipe(
64 Effect.flatMap((response) =>
65 response.status === 200
66 ? response.json
67 : Effect.fail(new Error(`GET ${url} returned ${response.status}`)),
68 ),
69 );
70
71const sendJson = (method: "POST" | "DELETE", url: string, body?: unknown) =>
72 HttpClient.execute(
73 body === undefined
74 ? HttpClientRequest.make(method)(url)
75 : HttpClientRequest.make(method)(url).pipe(
76 HttpClientRequest.bodyJsonUnsafe(body),
77 ),
78 ).pipe(
79 Effect.flatMap((response) =>
80 response.status === 200
81 ? response.json
82 : Effect.fail(
83 new Error(`${method} ${url} returned ${response.status}`),
84 ),
85 ),
86 );
87
88test.provider.skipIf(!!process.env.FAST)(
89 "ApiGateway bindings issue, meter, and manage API keys from a Lambda",
90 (stack) =>
91 Effect.gen(function* () {
92 yield* stack.destroy();
93 yield* reapApiKeys;
94
95 const { functionUrl } = yield* stack.deploy(
96 Effect.gen(function* () {
97 return yield* ApiGatewayBindingsFunction;
98 }).pipe(Effect.provide(ApiGatewayBindingsFunctionLive)),
99 );
100 expect(functionUrl).toBeTruthy();
101 const baseUrl = functionUrl!.replace(/\/+$/, "");
102
103 // CreateApiKey + CreateUsagePlanKey — retried through cold start.
104 const created = (yield* sendJson("POST", `${baseUrl}/keys`, {
105 name: keyName,
106 }).pipe(Effect.retry({ schedule: readinessSchedule }))) as {
107 id: string;
108 valueRedacted: boolean;
109 };
110 expect(created.id).toBeTruthy();
111 // The distilled response decodes the key material to Redacted.
112 expect(created.valueRedacted).toBe(true);
113
114 // GetApiKey
115 const single = (yield* getJson(`${baseUrl}/key?id=${created.id}`)) as {
116 id: string;
117 name: string;
118 enabled: boolean;
119 };
120 expect(single.id).toEqual(created.id);
121 expect(single.name).toEqual(keyName);
122 expect(single.enabled).toBe(true);
123
124 // GetApiKeys
125 const listed = (yield* getJson(
126 `${baseUrl}/keys?nameQuery=${keyName}`,
127 )) as { ids: string[] };
128 expect(listed.ids).toContain(created.id);
129
130 // GetUsagePlanKey / GetUsagePlanKeys — enrollment is visible.
131 const enrollment = (yield* getJson(
132 `${baseUrl}/plan-key?id=${created.id}`,
133 )) as { enrolled: boolean };
134 expect(enrollment.enrolled).toBe(true);
135 const planKeys = (yield* getJson(`${baseUrl}/plan-keys`)) as {
136 ids: string[];
137 };
138 expect(planKeys.ids).toContain(created.id);
139
140 // GetUsage — empty usage window is a valid, IAM-exercising read.
141 const today = new Date().toISOString().slice(0, 10);
142 const usage = (yield* getJson(
143 `${baseUrl}/usage?startDate=${today}&endDate=${today}`,
144 )) as { items: Record<string, unknown> };
145 expect(usage.items).toBeDefined();
146
147 // UpdateUsage — the plan has no quota, so the API answers with a
148 // typed BadRequest (extended: false). An IAM failure would be an
149 // (uncaught) auth error and fail the route with a 500.
150 const extended = (yield* sendJson("POST", `${baseUrl}/extend`, {
151 keyId: created.id,
152 })) as { extended: boolean };
153 expect(typeof extended.extended).toBe("boolean");
154
155 // UpdateApiKey — disable the key.
156 const disabled = (yield* sendJson("POST", `${baseUrl}/disable`, {
157 id: created.id,
158 })) as { enabled: boolean };
159 expect(disabled.enabled).toBe(false);
160
161 // FlushStageCache / FlushStageAuthorizersCache — no cache cluster on
162 // the stage, so the API answers with typed BadRequest/NotFound; the
163 // route reports the flush outcome instead of failing on auth.
164 const flushed = (yield* sendJson("POST", `${baseUrl}/flush`)) as {
165 cache: boolean;
166 authorizers: boolean;
167 };
168 expect(typeof flushed.cache).toBe("boolean");
169 expect(typeof flushed.authorizers).toBe("boolean");
170
171 // DeleteUsagePlanKey + DeleteApiKey — cleanup through the bindings.
172 const deleted = (yield* sendJson(
173 "DELETE",
174 `${baseUrl}/key?id=${created.id}`,
175 )) as { deleted: boolean };
176 expect(deleted.deleted).toBe(true);
177 const afterDelete = (yield* getJson(
178 `${baseUrl}/keys?nameQuery=${keyName}`,
179 )) as { ids: string[] };
180 expect(afterDelete.ids).not.toContain(created.id);
181
182 yield* stack.destroy();
183
184 // The list endpoint can briefly return a deleted key after GetApiKeys
185 // has already observed the deletion. Prove the runtime-created key is
186 // gone by its stable ID through the bounded typed NotFound assertion.
187 yield* assertApiKeyDeleted(created.id);
188 }).pipe(Effect.ensuring(reapApiKeys)),
189 {
190 tags: [
191 "provider:aws",
192 "provider:aws:apigateway",
193 "provider:aws:lambda",
194 "live",
195 ],
196 timeout: 600_000,
197 },
198);