1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import PaymentCryptographyTestFunctionLive, {
11 PaymentCryptographyTestFunction,
12} from "./handler.ts";
13import { reapLeakedKeys } from "./reapKeys.ts";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "PaymentCryptoBindings");
18
19// The whole suite deploys real Payment Cryptography keys (billed monthly),
20// so it is gated behind AWS_TEST_PAYMENTCRYPTO=1 alongside the Key lifecycle.
21const gated = !process.env.AWS_TEST_PAYMENTCRYPTO;
22
23const readinessPolicy = Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(60),
26]);
27
28let baseUrl: string;
29
30class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
31 readonly status: number;
32 readonly body: string;
33}> {}
34
35// Retry transient 5xx from cold re-inits; genuine 4xx fails immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58describe.skipIf(gated)(
59 "PaymentCryptography Bindings",
60 {
61 tags: [
62 "provider:aws",
63 "provider:aws:lambda",
64 "provider:aws:paymentcryptography",
65 "provider:aws:translate",
66 "live",
67 ],
68 },
69 () => {
70 beforeAll(
71 Effect.gen(function* () {
72 yield* sharedStack.destroy();
73 // Pre-clean: schedule deletion for any ACTIVE keys a previously
74 // crashed run leaked under this stack's tags (the scratch state is
75 // in-memory, so the destroy above cannot see them).
76 yield* Core.withProviders(
77 reapLeakedKeys([sharedStack.name]),
78 testOptions,
79 sharedStack.name,
80 );
81
82 const { functionUrl } = yield* sharedStack.deploy(
83 Effect.gen(function* () {
84 return yield* PaymentCryptographyTestFunction;
85 }).pipe(Effect.provide(PaymentCryptographyTestFunctionLive)),
86 );
87
88 expect(functionUrl).toBeTruthy();
89 baseUrl = functionUrl!.replace(/\/+$/, "");
90
91 yield* HttpClient.get(`${baseUrl}/health`).pipe(
92 Effect.flatMap((response) =>
93 response.status === 200
94 ? Effect.succeed(response)
95 : Effect.fail(
96 new Error(`Function not ready: ${response.status}`),
97 ),
98 ),
99 Effect.retry({ schedule: readinessPolicy }),
100 );
101 }).pipe(Effect.orDie),
102 { timeout: 240_000 },
103 );
104
105 // Destroy schedules key deletion (mandatory >=3 day window — keys cannot
106 // be hard-deleted, DELETE_PENDING is the terminal state a test can reach);
107 // the trailing reap catches any key the destroy missed so even a partial
108 // failure leaves nothing ACTIVE.
109 afterAll(
110 sharedStack
111 .destroy()
112 .pipe(
113 Effect.ensuring(
114 Core.withProviders(
115 reapLeakedKeys([sharedStack.name]),
116 testOptions,
117 sharedStack.name,
118 ),
119 ),
120 Effect.orDie,
121 ),
122 { timeout: 120_000 },
123 );
124
125 describe("EncryptData", () => {
126 test.provider("encrypts hex-encoded plaintext under the key", (_stack) =>
127 Effect.gen(function* () {
128 const response = (yield* send(
129 HttpClientRequest.bodyJsonUnsafe(
130 HttpClientRequest.post(`${baseUrl}/encrypt-decrypt`),
131 // "1234567890123456" — one full AES block, hex-encoded
132 { plainTextHex: "31323334353637383930313233343536" },
133 ),
134 ).pipe(Effect.flatMap((r) => r.json))) as {
135 keyArn: string;
136 cipherText: string;
137 plainText: string;
138 };
139
140 expect(response.keyArn).toContain(":key/");
141 expect(response.cipherText).toBeTruthy();
142 expect(response.cipherText).not.toBe(
143 "31323334353637383930313233343536",
144 );
145 }),
146 );
147 });
148
149 describe("DecryptData", () => {
150 test.provider("round-trips ciphertext back to the plaintext", (_stack) =>
151 Effect.gen(function* () {
152 const response = (yield* send(
153 HttpClientRequest.bodyJsonUnsafe(
154 HttpClientRequest.post(`${baseUrl}/encrypt-decrypt`),
155 { plainTextHex: "41414141414141414141414141414141" },
156 ),
157 ).pipe(Effect.flatMap((r) => r.json))) as {
158 plainText: string;
159 };
160
161 expect(response.plainText.toUpperCase()).toBe(
162 "41414141414141414141414141414141",
163 );
164 }),
165 );
166 });
167
168 describe("GenerateMac", () => {
169 test.provider("generates an HMAC over message data", (_stack) =>
170 Effect.gen(function* () {
171 const response = (yield* send(
172 HttpClientRequest.bodyJsonUnsafe(
173 HttpClientRequest.post(`${baseUrl}/mac`),
174 { messageDataHex: "31323334353637383930313233343536" },
175 ),
176 ).pipe(Effect.flatMap((r) => r.json))) as {
177 mac: string;
178 };
179
180 expect(response.mac).toBeTruthy();
181 expect(response.mac).toMatch(/^[0-9A-Fa-f]+$/);
182 }),
183 );
184 });
185
186 describe("VerifyMac", () => {
187 test.provider(
188 "verifies a generated MAC and rejects a tampered one",
189 (_stack) =>
190 Effect.gen(function* () {
191 const response = (yield* send(
192 HttpClientRequest.bodyJsonUnsafe(
193 HttpClientRequest.post(`${baseUrl}/mac`),
194 { messageDataHex: "39393939393939393939393939393939" },
195 ),
196 ).pipe(Effect.flatMap((r) => r.json))) as {
197 verifiedKeyArn: string;
198 tampered: string;
199 };
200
201 expect(response.verifiedKeyArn).toContain(":key/");
202 expect(response.tampered).toBe("verification-failed");
203 }),
204 );
205 });
206
207 describe("ReEncryptData", () => {
208 test.provider(
209 "translates DUKPT ciphertext to the working key and round-trips",
210 (_stack) =>
211 Effect.gen(function* () {
212 const response = (yield* send(
213 HttpClientRequest.bodyJsonUnsafe(
214 HttpClientRequest.post(`${baseUrl}/re-encrypt`),
215 // "ABCDABCDABCDABCD" — one full AES block, hex-encoded
216 { plainTextHex: "41424344414243444142434441424344" },
217 ),
218 ).pipe(Effect.flatMap((r) => r.json))) as {
219 outgoingKeyArn: string;
220 plainText: string;
221 };
222
223 expect(response.outgoingKeyArn).toContain(":key/");
224 expect(response.plainText.toUpperCase()).toBe(
225 "41424344414243444142434441424344",
226 );
227 }),
228 );
229 });
230
231 describe("GenerateCardValidationData", () => {
232 test.provider("generates a CVV2 for a PAN + expiry", (_stack) =>
233 Effect.gen(function* () {
234 const response = (yield* send(
235 HttpClientRequest.bodyJsonUnsafe(
236 HttpClientRequest.post(`${baseUrl}/card`),
237 { pan: "9123456789012345", expiry: "0130" },
238 ),
239 ).pipe(Effect.flatMap((r) => r.json))) as {
240 cvv2: string;
241 };
242
243 expect(response.cvv2).toMatch(/^\d{3}$/);
244 }),
245 );
246 });
247
248 describe("VerifyCardValidationData", () => {
249 test.provider(
250 "verifies the generated CVV2 and rejects a tampered one",
251 (_stack) =>
252 Effect.gen(function* () {
253 const response = (yield* send(
254 HttpClientRequest.bodyJsonUnsafe(
255 HttpClientRequest.post(`${baseUrl}/card`),
256 { pan: "9123456789012345", expiry: "0130" },
257 ),
258 ).pipe(Effect.flatMap((r) => r.json))) as {
259 verifiedKeyArn: string;
260 tampered: string;
261 };
262
263 expect(response.verifiedKeyArn).toContain(":key/");
264 expect(response.tampered).toBe("verification-failed");
265 }),
266 );
267 });
268
269 describe("GeneratePinData / VerifyPinData / TranslatePinData", () => {
270 test.provider(
271 "issues a Visa PIN, verifies the PVV, and translates the PIN block",
272 (_stack) =>
273 Effect.gen(function* () {
274 const response = (yield* send(
275 HttpClientRequest.bodyJsonUnsafe(
276 HttpClientRequest.post(`${baseUrl}/pin`),
277 { pan: "9123456789012345" },
278 ),
279 ).pipe(Effect.flatMap((r) => r.json))) as {
280 pvv: string;
281 verificationKeyArn: string;
282 translatedKeyArn: string;
283 translatedPinBlock: string;
284 };
285
286 expect(response.pvv).toMatch(/^\d+$/);
287 expect(response.verificationKeyArn).toContain(":key/");
288 // The translated block comes back under the outgoing PEK.
289 expect(response.translatedKeyArn).toContain(":key/");
290 expect(response.translatedKeyArn).not.toBe(
291 response.verificationKeyArn,
292 );
293 expect(response.translatedPinBlock).toMatch(/^[0-9A-Fa-f]+$/);
294 }),
295 );
296 });
297
298 describe("GetPublicKeyCertificate", () => {
299 test.provider(
300 "exports the public key certificate of the signing key pair",
301 (_stack) =>
302 Effect.gen(function* () {
303 const response = (yield* send(
304 HttpClientRequest.get(`${baseUrl}/public-key-cert`),
305 ).pipe(Effect.flatMap((r) => r.json))) as {
306 keyCertificate: string;
307 keyCertificateChain: string;
308 };
309
310 expect(response.keyCertificate.length).toBeGreaterThan(0);
311 expect(response.keyCertificateChain.length).toBeGreaterThan(0);
312 }),
313 );
314 });
315 },
316);