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 SignerTestFunctionLive, { SignerTestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "SignerBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25let functionArn: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30 readonly url: string;
31}> {}
32
33// The shared Lambda fixture occasionally answers a transient 5xx under load
34// (cold re-init, IAM propagation on the freshly attached policy that the
35// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
36// 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({
45 status: response.status,
46 body,
47 url: request.url,
48 }),
49 ),
50 ),
51 )
52 : Effect.succeed(response),
53 ),
54 Effect.retry({
55 while: (e) => e._tag === "TransientUpstream",
56 schedule: Schedule.max([
57 Schedule.exponential("500 millis"),
58 Schedule.recurs(6),
59 ]),
60 }),
61 );
62
63const getJson = (path: string) =>
64 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
65 Effect.flatMap((r) => r.json),
66 );
67
68const postJson = (path: string) =>
69 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
70 Effect.flatMap((r) => r.json),
71 );
72
73describe.sequential(
74 "Signer Bindings",
75 {
76 tags: [
77 "provider:aws",
78 "provider:aws:lambda",
79 "provider:aws:s3",
80 "provider:aws:signer",
81 "live",
82 ],
83 },
84 () => {
85 beforeAll(
86 Effect.gen(function* () {
87 yield* Effect.logInfo(
88 "Signer test setup: destroying previous resources",
89 );
90 yield* sharedStack.destroy();
91
92 yield* Effect.logInfo("Signer test setup: deploying fixture");
93 const attrs = yield* sharedStack.deploy(
94 Effect.gen(function* () {
95 return yield* SignerTestFunction;
96 }).pipe(Effect.provide(SignerTestFunctionLive)),
97 );
98
99 expect(attrs.functionUrl).toBeTruthy();
100 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
101 functionArn = attrs.functionArn;
102
103 const readinessUrl = `${baseUrl}/bindings`;
104 yield* Effect.logInfo(
105 `Signer test setup: probing readiness at ${readinessUrl}`,
106 );
107 yield* HttpClient.get(readinessUrl).pipe(
108 Effect.flatMap((response) =>
109 response.status === 200
110 ? Effect.succeed(response)
111 : Effect.fail(
112 new Error(`Function not ready: ${response.status}`),
113 ),
114 ),
115 Effect.tapError((error) =>
116 Effect.logWarning(
117 `Signer test setup: fixture not ready yet (${String(error)})`,
118 ),
119 ),
120 Effect.retry({ schedule: readinessPolicy }),
121 );
122 }),
123 { timeout: 300_000 },
124 );
125
126 afterAll(process.env.NO_DESTROY ? Effect.void : sharedStack.destroy(), {
127 timeout: 180_000,
128 });
129
130 describe("binding registration", () => {
131 test.provider(
132 "all nine capabilities initialize in the runtime",
133 (_stack) =>
134 Effect.gen(function* () {
135 const response = (yield* getJson("/bindings")) as {
136 bound: string[];
137 };
138 expect(response.bound).toHaveLength(9);
139 expect(response.bound).toContain("startSigningJob");
140 expect(response.bound).toContain("signPayload");
141 expect(response.bound).toContain("revokeSigningProfile");
142 expect(response.bound).toContain("getRevocationStatus");
143 }),
144 );
145 });
146
147 describe("ListSigningPlatforms / GetSigningPlatform", () => {
148 test.provider("reads the AWS-managed platform catalog", (_stack) =>
149 Effect.gen(function* () {
150 const platforms = (yield* getJson("/platforms")) as { ids: string[] };
151 expect(platforms.ids.length).toBeGreaterThan(0);
152 expect(platforms.ids).toContain("AWSLambda-SHA384-ECDSA");
153
154 const platform = (yield* getJson(
155 "/platform?id=AWSLambda-SHA384-ECDSA",
156 )) as { platformId: string; revocationSupported: boolean };
157 expect(platform.platformId).toBe("AWSLambda-SHA384-ECDSA");
158 }),
159 );
160 });
161
162 describe("StartSigningJob / DescribeSigningJob / ListSigningJobs / RevokeSignature / GetRevocationStatus", () => {
163 test.provider(
164 "signs a zip end-to-end, observes the job, revokes its signature",
165 (_stack) =>
166 Effect.gen(function* () {
167 // Start: freshly-propagated IAM on the source/destination buckets
168 // can transiently reject the S3 access — re-poll bounded.
169 const started = (yield* postJson("/start").pipe(
170 Effect.repeat({
171 schedule: Schedule.spaced("5 seconds"),
172 until: (r): boolean => (r as { ok: boolean }).ok === true,
173 times: 10,
174 }),
175 )) as {
176 ok: boolean;
177 jobId?: string;
178 tag?: string;
179 message?: string;
180 };
181 if (!started.ok) {
182 throw new Error(
183 `StartSigningJob failed: ${started.tag}: ${started.message}`,
184 );
185 }
186 const jobId = started.jobId!;
187 const query = `?id=${encodeURIComponent(jobId)}`;
188
189 // Describe: poll (bounded) until the async signing job lands. A
190 // just-started job can 404 briefly ("Pending") before it appears.
191 const job = (yield* getJson(`/job${query}`).pipe(
192 Effect.repeat({
193 schedule: Schedule.spaced("3 seconds"),
194 until: (r): boolean => {
195 const status = (r as { status: string }).status;
196 return status !== "InProgress" && status !== "Pending";
197 },
198 times: 20,
199 }),
200 )) as { status: string; signedKey?: string };
201 expect(job.status).toBe("Succeeded");
202 expect(job.signedKey).toContain("signed/");
203
204 // List: the job is enumerated account-wide.
205 const jobs = (yield* getJson("/jobs")) as { jobIds: string[] };
206 expect(jobs.jobIds).toContain(jobId);
207
208 // Revocation status check: derived ARNs round-trip through the
209 // data endpoint; Signer may reject the empty certificate-hash list
210 // with a typed ValidationException — either proves binding + IAM.
211 const revocation = (yield* getJson(`/revocation${query}`)) as {
212 ok: boolean;
213 revokedEntities?: string[];
214 tag?: string;
215 };
216 if (revocation.ok) {
217 expect(revocation.revokedEntities).toEqual([]);
218 } else {
219 expect(["ValidationException", "BadRequestException"]).toContain(
220 revocation.tag,
221 );
222 }
223
224 // Revoke: the Lambda platform supports revocation, so revoking the
225 // completed job's signature must land.
226 const revoked = (yield* postJson(`/revoke-job${query}`)) as {
227 revoked: boolean;
228 tag?: string;
229 };
230 expect(revoked.revoked).toBe(true);
231 }),
232 { timeout: 240_000 },
233 );
234 });
235
236 describe("SignPayload", () => {
237 test.provider(
238 "synchronously signs a Notation payload with the bound profile",
239 (_stack) =>
240 Effect.gen(function* () {
241 const signed = (yield* postJson("/sign")) as {
242 ok: boolean;
243 jobId?: string;
244 hasSignature?: boolean;
245 tag?: string;
246 };
247 if (signed.ok) {
248 expect(signed.hasSignature).toBe(true);
249 expect(signed.jobId).toBeTruthy();
250 } else {
251 // A typed rejection of the payload still proves the binding and
252 // IAM round-trip; an AccessDeniedException would not.
253 expect(["ValidationException", "BadRequestException"]).toContain(
254 signed.tag,
255 );
256 }
257 }),
258 );
259 });
260
261 describe(
262 "consumeSigningJobEvents",
263 { tags: ["provider:aws:eventbridge"] },
264 () => {
265 test.provider(
266 "the deploy created an EventBridge rule targeting the function",
267 (_stack) =>
268 Effect.gen(function* () {
269 // Out-of-band via distilled: the fixture's consumeSigningJobEvents
270 // must have materialized as a rule on the default bus with the
271 // Lambda as target.
272 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
273 TargetArn: functionArn,
274 });
275 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
276 }),
277 );
278 },
279 );
280 },
281);