1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import SyntheticsBindingsFunctionLive, {
12 SyntheticsBindingsFunction,
13} from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "SyntheticsBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26let baseUrl: string;
27let functionArn: string;
28
29class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
30 readonly status: number;
31 readonly body: string;
32}> {}
33
34// The shared Lambda fixture occasionally answers a transient 5xx under load
35// (cold re-init, IAM propagation on the freshly attached policy). Retry only
36// 5xx; a genuine 4xx/assertion failure surfaces immediately.
37const send = (request: HttpClientRequest.HttpClientRequest) =>
38 HttpClient.execute(request).pipe(
39 Effect.flatMap((response) =>
40 response.status >= 500
41 ? response.text.pipe(
42 Effect.flatMap((body) =>
43 Effect.fail(
44 new TransientUpstream({ status: response.status, body }),
45 ),
46 ),
47 )
48 : Effect.succeed(response),
49 ),
50 Effect.retry({
51 while: (e) => e._tag === "TransientUpstream",
52 schedule: Schedule.max([
53 Schedule.exponential("500 millis"),
54 Schedule.recurs(6),
55 ]),
56 }),
57 );
58
59const getJson = (path: string) =>
60 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
61 Effect.flatMap((r) => r.json),
62 );
63
64// The role policy granting synthetics:* is attached moments before the
65// tests — IAM propagation can lag ~10-30s, surfacing as AccessDenied from a
66// route. Retry (bounded) until the grant lands.
67const getJsonUntilGranted = (path: string) =>
68 getJson(path).pipe(
69 Effect.map((r) => r as { tag: string }),
70 Effect.repeat({
71 schedule: Schedule.spaced("5 seconds"),
72 until: (r): boolean => r.tag !== "AccessDeniedException",
73 times: 10,
74 }),
75 );
76
77describe.sequential(
78 "Synthetics Bindings",
79 {
80 tags: [
81 "provider:aws",
82 "provider:aws:lambda",
83 "provider:aws:s3",
84 "provider:aws:synthetics",
85 "live",
86 ],
87 },
88 () => {
89 beforeAll(
90 Effect.gen(function* () {
91 yield* Effect.logInfo(
92 "Synthetics test setup: destroying previous resources",
93 );
94 yield* sharedStack.destroy();
95
96 yield* Effect.logInfo("Synthetics test setup: deploying fixture");
97 const attrs = yield* sharedStack.deploy(
98 Effect.gen(function* () {
99 return yield* SyntheticsBindingsFunction;
100 }).pipe(Effect.provide(SyntheticsBindingsFunctionLive)),
101 );
102
103 expect(attrs.functionUrl).toBeTruthy();
104 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
105 functionArn = attrs.functionArn;
106
107 const readinessUrl = `${baseUrl}/bindings`;
108 yield* Effect.logInfo(
109 `Synthetics test setup: probing readiness at ${readinessUrl}`,
110 );
111 yield* HttpClient.get(readinessUrl).pipe(
112 Effect.flatMap((response) =>
113 response.status === 200
114 ? Effect.succeed(response)
115 : Effect.fail(
116 new Error(`Function not ready: ${response.status}`),
117 ),
118 ),
119 Effect.tapError((error) =>
120 Effect.logWarning(
121 `Synthetics test setup: fixture not ready yet (${String(error)})`,
122 ),
123 ),
124 Effect.retry({ schedule: readinessPolicy }),
125 );
126 }),
127 { timeout: 420_000 },
128 );
129
130 afterAll(process.env.NO_DESTROY ? Effect.void : sharedStack.destroy(), {
131 timeout: 420_000,
132 });
133
134 describe("binding registration", () => {
135 test.provider("all 5 capabilities initialize in the runtime", (_stack) =>
136 Effect.gen(function* () {
137 const response = (yield* getJson("/bindings")) as { bound: string[] };
138 expect(response.bound).toHaveLength(5);
139 expect(response.bound).toContain("getCanary");
140 expect(response.bound).toContain("getCanaryRuns");
141 expect(response.bound).toContain("startCanary");
142 expect(response.bound).toContain("stopCanary");
143 expect(response.bound).toContain("describeCanariesLastRun");
144 }),
145 );
146 });
147
148 describe("GetCanary", () => {
149 test.provider(
150 "reads the bound canary's state (name injected)",
151 (_stack) =>
152 Effect.gen(function* () {
153 const response = (yield* getJsonUntilGranted("/canary")) as {
154 tag: string;
155 state?: string;
156 };
157 expect(response.tag).toBe("ok");
158 // Never started → READY (STOPPED once a run has completed).
159 expect(["READY", "STOPPED", "RUNNING", "STARTING"]).toContain(
160 response.state,
161 );
162 }),
163 { timeout: 120_000 },
164 );
165 });
166
167 describe("GetCanaryRuns", () => {
168 test.provider("lists runs on the bound canary", (_stack) =>
169 Effect.gen(function* () {
170 const response = (yield* getJsonUntilGranted("/runs")) as {
171 tag: string;
172 count?: number;
173 };
174 expect(response.tag).toBe("ok");
175 // A never-started canary has no runs; a zero count still proves the
176 // grant + name injection round-tripped.
177 expect(response.count).toBeGreaterThanOrEqual(0);
178 }),
179 );
180 });
181
182 describe("DescribeCanariesLastRun", () => {
183 test.provider("reads last runs account-wide", (_stack) =>
184 Effect.gen(function* () {
185 const response = (yield* getJsonUntilGranted("/last-run")) as {
186 tag: string;
187 count?: number;
188 };
189 expect(response.tag).toBe("ok");
190 // Canaries that have never run report no last-run entry — a zero
191 // count still proves synthetics:DescribeCanariesLastRun round-trips.
192 expect(response.count).toBeGreaterThanOrEqual(0);
193 }),
194 );
195 });
196
197 describe("StopCanary", () => {
198 test.provider(
199 "typed rejection when the canary is not running (grant proven)",
200 (_stack) =>
201 Effect.gen(function* () {
202 // The canary was deployed stopped (READY); stopping it is a
203 // ConflictException — the typed tag proves synthetics:StopCanary
204 // reached the API.
205 const response = (yield* getJsonUntilGranted("/stop")) as {
206 tag: string;
207 };
208 expect(["ok", "ConflictException"]).toContain(response.tag);
209 }),
210 );
211 });
212
213 describe("StartCanary", () => {
214 test.provider(
215 "starts the canary for its single scheduled run",
216 (_stack) =>
217 Effect.gen(function* () {
218 const response = (yield* getJsonUntilGranted("/start")) as {
219 tag: string;
220 };
221 expect(response.tag).toBe("ok");
222 // A one-shot canary can traverse READY -> RUNNING -> READY between
223 // state polls. Prove the accepted start by observing its run record
224 // instead of relying on a transient control-plane state.
225 const started = yield* getJson("/runs").pipe(
226 Effect.map((r) => r as { tag: string; count?: number }),
227 Effect.repeat({
228 schedule: Schedule.spaced("2 seconds"),
229 until: (r): boolean => r.tag === "ok" && (r.count ?? 0) > 0,
230 times: 25,
231 }),
232 );
233 expect(started.tag).toBe("ok");
234 expect(started.count).toBeGreaterThan(0);
235 }),
236 { timeout: 120_000 },
237 );
238 });
239
240 describe(
241 "consumeCanaryEvents",
242 { tags: ["provider:aws:eventbridge"] },
243 () => {
244 test.provider(
245 "the deploy created an EventBridge rule targeting the function",
246 (_stack) =>
247 Effect.gen(function* () {
248 // Out-of-band via distilled: the fixture's consumeCanaryEvents
249 // must have materialized as a rule on the default bus with the
250 // Lambda as target.
251 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
252 TargetArn: functionArn,
253 });
254 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
255 }),
256 );
257 },
258 );
259 },
260);