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 SESTestFunctionLive, { SESTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "SESBindings");
15
16// A verified from-address on the account. It does NOT unlock a successful
17// `SendEmail` — that binding is scoped to the identity the fixture binds — but
18// the suppression-list write plane needs production access, and an account
19// that has a verified sender is invariably out of the sandbox.
20const VERIFIED_FROM = process.env.AWS_TEST_SES_FROM;
21
22// Names an existing custom verification email template. Creating one requires
23// a verified sender, so the fixture cannot declare it — pass a template you
24// created out of band to exercise the real send.
25const CVE_TEMPLATE = process.env.AWS_TEST_SES_CVE_TEMPLATE;
26
27// Recipient for the real custom-verification send. Defaults to the mailbox
28// simulator; override only with an address you own.
29const CVE_RECIPIENT = process.env.AWS_TEST_SES_CVE_RECIPIENT;
30
31// Never skips a test — tightens it. With Virtual Deliverability Manager
32// enabled the insight bindings must return real data rather than the typed
33// rejection a VDM-less account gives.
34const VDM_ENABLED = !!process.env.AWS_TEST_SES_VDM;
35
36// The SES mailbox simulator accepts mail without affecting reputation.
37const SIMULATOR = "success@simulator.amazonses.com";
38
39// A syntactically valid address at the fixture's (unverified) domain
40// identity — SES rejects it with the typed MessageRejected tag in sandbox.
41const UNVERIFIED_FROM = "noreply@ses-bindings.alchemy-test.example.com";
42
43// Deterministic address the suppression-list tests add and remove. The test
44// ends by deleting it, so repeated runs leave no residue.
45const SUPPRESSED_ADDRESS = "suppressed@ses-bindings.alchemy-test.example.com";
46
47// The tags SES raises about a REQUEST — a bad argument, a missing object, or
48// an unentitled account. A binding that failed to attach its IAM action would
49// answer AccessDeniedException instead, so asserting membership here (rather
50// than `typeof error === "string"`) is what makes these tests able to fail.
51const REQUEST_TAGS = ["BadRequestException", "NotFoundException"];
52
53// SendCustomVerificationEmail additionally rejects an unverified sender and a
54// sandboxed account through its own tags.
55const CVE_REQUEST_TAGS = [
56 ...REQUEST_TAGS,
57 "MessageRejected",
58 "MailFromDomainNotVerifiedException",
59 "SendingPausedException",
60 "LimitExceededException",
61];
62
63const readinessPolicy = Schedule.max([
64 Schedule.fixed("2 seconds"),
65 Schedule.recurs(75),
66]);
67
68let baseUrl: string;
69
70class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
71 readonly status: number;
72 readonly body: string;
73}> {}
74
75// Retry transient 5xx only; a genuine 4xx/assertion failure surfaces
76// immediately.
77const send = (request: HttpClientRequest.HttpClientRequest) =>
78 HttpClient.execute(request).pipe(
79 Effect.flatMap((response) =>
80 response.status >= 500
81 ? response.text.pipe(
82 Effect.flatMap((body) =>
83 Effect.fail(
84 new TransientUpstream({ status: response.status, body }),
85 ),
86 ),
87 )
88 : Effect.succeed(response),
89 ),
90 Effect.retry({
91 while: (e) => e._tag === "TransientUpstream",
92 schedule: Schedule.max([
93 Schedule.exponential("500 millis"),
94 Schedule.recurs(6),
95 ]),
96 }),
97 );
98
99describe(
100 "SES Bindings",
101 {
102 tags: [
103 "provider:aws",
104 "provider:aws:batch",
105 "provider:aws:lambda",
106 "provider:aws:ses",
107 "live",
108 ],
109 },
110 () => {
111 beforeAll(
112 Effect.gen(function* () {
113 yield* Effect.logInfo("SES test setup: destroying previous resources");
114 yield* sharedStack.destroy();
115
116 yield* Effect.logInfo("SES test setup: deploying fixture");
117 const { functionUrl } = yield* sharedStack.deploy(
118 Effect.gen(function* () {
119 return yield* SESTestFunction;
120 }).pipe(Effect.provide(SESTestFunctionLive)),
121 );
122
123 expect(functionUrl).toBeTruthy();
124 baseUrl = functionUrl!.replace(/\/+$/, "");
125 const readinessUrl = `${baseUrl}/health`;
126
127 yield* Effect.logInfo(
128 `SES test setup: probing readiness at ${readinessUrl}`,
129 );
130 yield* HttpClient.get(readinessUrl).pipe(
131 Effect.flatMap((response) =>
132 response.status === 200
133 ? Effect.succeed(response)
134 : Effect.fail(
135 new Error(`Function not ready: ${response.status}`),
136 ),
137 ),
138 Effect.tapError((error) =>
139 Effect.logWarning(
140 `SES test setup: fixture not ready yet (${String(error)})`,
141 ),
142 ),
143 Effect.retry({ schedule: readinessPolicy }),
144 );
145
146 // The freshly attached role policy takes a while to propagate through
147 // IAM — poll the send route until SES stops answering AccessDenied so
148 // the tests below observe the real (sandbox) behavior.
149 yield* HttpClient.execute(
150 HttpClientRequest.post(
151 `${baseUrl}/send-simple?from=${encodeURIComponent(UNVERIFIED_FROM)}`,
152 ),
153 ).pipe(
154 Effect.flatMap((r) => r.json),
155 Effect.flatMap((body) =>
156 (body as { error?: string }).error === "AccessDeniedException"
157 ? Effect.fail(new Error("IAM policy not propagated yet"))
158 : Effect.succeed(body),
159 ),
160 Effect.tapError((error) =>
161 Effect.logWarning(
162 `SES test setup: send not authorized yet (${String(error)})`,
163 ),
164 ),
165 Effect.retry({ schedule: readinessPolicy }),
166 );
167 }),
168 { timeout: 240_000 },
169 );
170
171 afterAll(sharedStack.destroy(), { timeout: 120_000 });
172
173 describe("SendEmail", () => {
174 test.provider(
175 "sandbox: unverified sender surfaces the typed MessageRejected tag through the binding",
176 (_stack) =>
177 Effect.gen(function* () {
178 const response = (yield* send(
179 HttpClientRequest.post(
180 `${baseUrl}/send-simple?from=${encodeURIComponent(UNVERIFIED_FROM)}`,
181 ),
182 ).pipe(Effect.flatMap((r) => r.json))) as {
183 messageId?: string;
184 error?: string;
185 message?: string;
186 };
187
188 // Sandbox + unverified FROM identity: SES rejects the message with
189 // the typed MessageRejected error ("Email address is not
190 // verified"). This proves the binding wires IAM + request
191 // marshalling correctly all the way into the deployed Lambda.
192 // SendingPausedException: the testing account's SES sending is
193 // paused. Still a typed SES error through the binding (not IAM
194 // AccessDenied / an unknown tag) — the binding is wired. The
195 // sandbox MessageRejected path is what we get when sending is on.
196 expect(["MessageRejected", "SendingPausedException"]).toContain(
197 response.error,
198 );
199 if (response.error === "MessageRejected") {
200 expect(response.message).toContain("not verified");
201 }
202 }),
203 );
204
205 test.provider(
206 "sandbox: templated send is rejected with the same typed tag",
207 (_stack) =>
208 Effect.gen(function* () {
209 const response = (yield* send(
210 HttpClientRequest.post(
211 `${baseUrl}/send-template?from=${encodeURIComponent(UNVERIFIED_FROM)}`,
212 ),
213 ).pipe(Effect.flatMap((r) => r.json))) as {
214 messageId?: string;
215 error?: string;
216 };
217 expect(["MessageRejected", "SendingPausedException"]).toContain(
218 response.error,
219 );
220 }),
221 );
222
223 test.provider(
224 "a binding declared without a configuration set is granted the same send",
225 (_stack) =>
226 Effect.gen(function* () {
227 // The fixture binds SendEmail twice: once with a configuration set
228 // and once without (`SES.SendEmail(identity)`). The no-config-set
229 // grant is a separate IAM policy, so it needs its own proof that it
230 // reaches SES — otherwise a regression in the one-argument overload
231 // would go unnoticed.
232 const response = (yield* send(
233 HttpClientRequest.post(
234 `${baseUrl}/send-plain?from=${encodeURIComponent(UNVERIFIED_FROM)}`,
235 ),
236 ).pipe(Effect.flatMap((r) => r.json))) as {
237 messageId?: string;
238 error?: string;
239 message?: string;
240 };
241
242 // Same sandbox rejection as the config-set path — crucially NOT
243 // AccessDenied, which is what a missing/misscoped policy looks like.
244 expect(response.error).not.toBe("AccessDeniedException");
245 expect(["MessageRejected", "SendingPausedException"]).toContain(
246 response.error,
247 );
248 if (response.error === "MessageRejected") {
249 expect(response.message).toContain("not verified");
250 }
251 }),
252 );
253
254 // `SendEmail` is identity-scoped: the binding grants ses:SendEmail only on
255 // the ARN of the identity it was bound to (plus that domain's addresses,
256 // its templates, and the configuration set). A sender outside that
257 // identity is refused by IAM before SES ever evaluates the message —
258 // including one that is verified on the account. Verified in a live
259 // production account with a verified sender, where the send returns
260 // AccessDeniedException rather than a MessageId.
261 //
262 // Exercising a genuine successful send therefore needs an identity the
263 // FIXTURE binds and that is really verified, i.e. a domain under our
264 // control with DKIM published — the setup Receiving.smoke.test.ts builds
265 // on the standing Cloudflare test zone. AWS_TEST_SES_FROM alone cannot
266 // grant it.
267 test.provider(
268 "a sender outside the bound identity is denied by the scoped IAM policy",
269 (_stack) =>
270 Effect.gen(function* () {
271 const outsider = encodeURIComponent(
272 "sender@not-the-bound-domain.test",
273 );
274 const response = (yield* send(
275 HttpClientRequest.post(`${baseUrl}/send-simple?from=${outsider}`),
276 ).pipe(Effect.flatMap((r) => r.json))) as {
277 messageId?: string;
278 error?: string;
279 };
280
281 // SendingPaused is evaluated before IAM on a paused account —
282 // still typed, still not a successful send.
283 expect([
284 "AccessDeniedException",
285 "SendingPausedException",
286 ]).toContain(response.error);
287 expect(response.messageId).toBeUndefined();
288 }),
289 );
290 });
291
292 describe("SendBulkEmail", () => {
293 test.provider(
294 "sandbox: bulk templated send from an unverified sender is rejected with the typed tag",
295 (_stack) =>
296 Effect.gen(function* () {
297 const response = (yield* send(
298 HttpClientRequest.post(
299 `${baseUrl}/send-bulk?from=${encodeURIComponent(UNVERIFIED_FROM)}`,
300 ),
301 ).pipe(Effect.flatMap((r) => r.json))) as {
302 error?: string;
303 results?: { status?: string; messageId?: string }[];
304 };
305
306 // Sandbox + unverified FROM: SES rejects either the whole request
307 // (typed MessageRejected) or the individual entry
308 // (Status MESSAGE_REJECTED) — both prove the binding wires IAM and
309 // request marshalling into the deployed Lambda.
310 if (response.error !== undefined) {
311 expect(response.error).toBe("MessageRejected");
312 } else {
313 expect(response.results?.[0]?.status).toBe("MESSAGE_REJECTED");
314 }
315 }),
316 );
317 });
318
319 describe("RenderEmailTemplate", () => {
320 test.provider(
321 "renders the bound template server-side with personalization data",
322 (_stack) =>
323 Effect.gen(function* () {
324 const response = (yield* send(
325 HttpClientRequest.post(`${baseUrl}/render-template`),
326 ).pipe(Effect.flatMap((r) => r.json))) as {
327 rendered?: string;
328 error?: string;
329 };
330 expect(response.error).toBeUndefined();
331 expect(response.rendered).toContain("Hello, Ada!");
332 }),
333 );
334 });
335
336 describe("GetAccount", () => {
337 test.provider("reads the account's sending status and quota", (_stack) =>
338 Effect.gen(function* () {
339 const response = (yield* send(
340 HttpClientRequest.get(`${baseUrl}/account`),
341 ).pipe(Effect.flatMap((r) => r.json))) as {
342 sendingEnabled?: boolean;
343 productionAccess?: boolean;
344 max24HourSend?: number;
345 error?: string;
346 };
347 expect(response.error).toBeUndefined();
348 expect(typeof response.sendingEnabled).toBe("boolean");
349 expect(typeof response.productionAccess).toBe("boolean");
350 }),
351 );
352 });
353
354 describe("Suppression List", () => {
355 test.provider(
356 "sandbox: reads work and the write surfaces the typed BadRequestException",
357 (_stack) =>
358 Effect.gen(function* () {
359 const email = encodeURIComponent(SUPPRESSED_ADDRESS);
360
361 // put — sandbox accounts cannot write to the suppression list;
362 // SES rejects with the typed BadRequestException ("Your account
363 // is still in the sandbox."). That proves the binding wires IAM
364 // and marshalling into the deployed Lambda.
365 const put = (yield* send(
366 HttpClientRequest.post(`${baseUrl}/suppress?email=${email}`),
367 ).pipe(Effect.flatMap((r) => r.json))) as {
368 error?: string;
369 message?: string;
370 };
371 if (put.error === undefined) {
372 // Production account: the write succeeded — assert that rather
373 // than letting an empty branch pass, then clean up and let the
374 // gated lifecycle test below cover the full flow.
375 expect(put.error).toBeUndefined();
376 const cleanup = (yield* send(
377 HttpClientRequest.post(`${baseUrl}/unsuppress?email=${email}`),
378 ).pipe(Effect.flatMap((r) => r.json))) as { error?: string };
379 expect(cleanup.error).toBeUndefined();
380 } else {
381 expect(put.error).toBe("BadRequestException");
382 expect(put.message).toContain("sandbox");
383
384 // get of a never-suppressed address — typed NotFoundException.
385 const missing = (yield* send(
386 HttpClientRequest.get(`${baseUrl}/suppressed?email=${email}`),
387 ).pipe(Effect.flatMap((r) => r.json))) as { error?: string };
388 expect(missing.error).toBe("NotFoundException");
389 }
390
391 // list — the read plane works even in the sandbox.
392 const list = (yield* send(
393 HttpClientRequest.get(`${baseUrl}/suppressed-list`),
394 ).pipe(Effect.flatMap((r) => r.json))) as {
395 emails?: string[];
396 error?: string;
397 };
398 expect(list.error).toBeUndefined();
399 expect(Array.isArray(list.emails)).toBe(true);
400 }),
401 );
402
403 // Full write lifecycle needs production access (the sandbox blocks
404 // PutSuppressedDestination with BadRequestException: "Your account is
405 // still in the sandbox.") — gated with the same env var as real sends.
406 test.provider.skipIf(!VERIFIED_FROM)(
407 "put, get, list, and delete a suppressed destination (AWS_TEST_SES_FROM)",
408 (_stack) =>
409 Effect.gen(function* () {
410 const email = encodeURIComponent(SUPPRESSED_ADDRESS);
411
412 // put
413 const put = (yield* send(
414 HttpClientRequest.post(`${baseUrl}/suppress?email=${email}`),
415 ).pipe(Effect.flatMap((r) => r.json))) as { error?: string };
416 expect(put.error).toBeUndefined();
417
418 // get — the suppression list is eventually consistent; poll until
419 // the entry materializes.
420 const got = yield* send(
421 HttpClientRequest.get(`${baseUrl}/suppressed?email=${email}`),
422 ).pipe(
423 Effect.flatMap((r) => r.json),
424 Effect.map(
425 (body) =>
426 body as { email?: string; reason?: string; error?: string },
427 ),
428 Effect.repeat({
429 schedule: Schedule.spaced("2 seconds"),
430 until: (body): boolean => body.reason === "BOUNCE",
431 times: 10,
432 }),
433 );
434 expect(got.email).toBe(SUPPRESSED_ADDRESS);
435 expect(got.reason).toBe("BOUNCE");
436
437 // list — filtered by reason, contains the address.
438 const list = yield* send(
439 HttpClientRequest.get(`${baseUrl}/suppressed-list`),
440 ).pipe(
441 Effect.flatMap((r) => r.json),
442 Effect.map(
443 (body) => body as { emails?: string[]; error?: string },
444 ),
445 Effect.repeat({
446 schedule: Schedule.spaced("2 seconds"),
447 until: (body): boolean =>
448 (body.emails ?? []).includes(SUPPRESSED_ADDRESS),
449 times: 10,
450 }),
451 );
452 expect(list.emails).toContain(SUPPRESSED_ADDRESS);
453
454 // delete — then the get surfaces the typed NotFoundException.
455 const del = (yield* send(
456 HttpClientRequest.post(`${baseUrl}/unsuppress?email=${email}`),
457 ).pipe(Effect.flatMap((r) => r.json))) as { error?: string };
458 expect(del.error).toBeUndefined();
459
460 const gone = yield* send(
461 HttpClientRequest.get(`${baseUrl}/suppressed?email=${email}`),
462 ).pipe(
463 Effect.flatMap((r) => r.json),
464 Effect.map((body) => body as { error?: string }),
465 Effect.repeat({
466 schedule: Schedule.spaced("2 seconds"),
467 until: (body): boolean => body.error === "NotFoundException",
468 times: 10,
469 }),
470 );
471 expect(gone.error).toBe("NotFoundException");
472 }),
473 { timeout: 120_000 },
474 );
475 });
476
477 describe("SendBounce", () => {
478 // A real bounce requires a message SES actually received within the last
479 // 24h; set this to that message id to exercise the success path.
480 const BOUNCE_MESSAGE_ID = process.env.AWS_TEST_SES_BOUNCE_MESSAGE_ID;
481
482 test.provider(
483 "bouncing a non-existent message surfaces a typed SES error through the binding",
484 (_stack) =>
485 Effect.gen(function* () {
486 const response = (yield* send(
487 HttpClientRequest.post(`${baseUrl}/send-bounce`),
488 ).pipe(Effect.flatMap((r) => r.json))) as {
489 messageId?: string;
490 error?: string;
491 };
492
493 // No real received message backs the fabricated OriginalMessageId
494 // (and the bounce sender is unverified in the sandbox), so SES
495 // rejects the bounce with the typed MessageRejected tag — verified
496 // live: "Failed to generate a bounce for <id>. The following
497 // identities are not verified: ...". That proves the binding wires
498 // ses:SendBounce IAM + request marshalling into the deployed Lambda.
499 expect(response.error).toBe("MessageRejected");
500 expect(response.messageId).toBeUndefined();
501 }),
502 );
503
504 test.provider.skipIf(!BOUNCE_MESSAGE_ID)(
505 "bounces a real received message (AWS_TEST_SES_BOUNCE_MESSAGE_ID)",
506 (_stack) =>
507 Effect.gen(function* () {
508 const response = (yield* send(
509 HttpClientRequest.post(
510 `${baseUrl}/send-bounce?messageId=${encodeURIComponent(BOUNCE_MESSAGE_ID!)}`,
511 ),
512 ).pipe(Effect.flatMap((r) => r.json))) as {
513 messageId?: string;
514 error?: string;
515 };
516 expect(response.error).toBeUndefined();
517 expect(response.messageId).toBeTruthy();
518 }),
519 );
520 });
521
522 describe("SendCustomVerificationEmail", () => {
523 // The unknown-template case never leaves SES, so its recipient is
524 // irrelevant. The gated case sends for real, so it goes to the mailbox
525 // simulator, which accepts mail without touching reputation — an address
526 // at the fixture's example.com domain would hard-bounce.
527 const sendCustomVerification = (template?: string) => {
528 const email = encodeURIComponent(
529 template
530 ? (CVE_RECIPIENT ?? SIMULATOR)
531 : "verify-target@ses-bindings.alchemy-test.example.com",
532 );
533 const query = template
534 ? `?email=${email}&template=${encodeURIComponent(template)}`
535 : `?email=${email}`;
536 return send(
537 HttpClientRequest.post(`${baseUrl}/send-custom-verification${query}`),
538 ).pipe(Effect.flatMap((r) => r.json)) as Effect.Effect<
539 { messageId?: string; error?: string; message?: string },
540 any,
541 any
542 >;
543 };
544
545 test.provider(
546 "an unknown template surfaces a typed SES error through the binding",
547 (_stack) =>
548 Effect.gen(function* () {
549 const response = yield* sendCustomVerification();
550
551 // The fixture declares no template — SES rejects
552 // CreateCustomVerificationEmailTemplate unless its sender is already
553 // verified, which a bare account has none of. So the request names a
554 // template that does not resolve and SES answers with a typed error.
555 //
556 // The tag must be one SES raises about the REQUEST. AccessDenied
557 // would mean the binding failed to attach
558 // ses:SendCustomVerificationEmail — the regression this test exists
559 // to catch — so it is asserted against explicitly.
560 expect(response.error).not.toBe("AccessDeniedException");
561 expect(CVE_REQUEST_TAGS).toContain(response.error);
562 expect(response.messageId).toBeUndefined();
563 }),
564 );
565
566 // The real send goes through a SECOND binding, scoped by reference to the
567 // address being verified. It cannot reuse the binding above: SES
568 // authorizes this action against the identity of the RECIPIENT, and that
569 // binding only covers addresses at the fixture's own domain.
570 //
571 // The reference form takes no ownership, so nothing here adopts or
572 // destroys the account's real identities.
573 test.provider.skipIf(!CVE_TEMPLATE || !CVE_RECIPIENT)(
574 "sends through a real template (AWS_TEST_SES_CVE_TEMPLATE + _RECIPIENT)",
575 (_stack) =>
576 Effect.gen(function* () {
577 const email = encodeURIComponent(CVE_RECIPIENT!);
578 const response = (yield* send(
579 HttpClientRequest.post(
580 `${baseUrl}/send-custom-verification-verified?email=${email}&template=${encodeURIComponent(CVE_TEMPLATE!)}`,
581 ),
582 ).pipe(Effect.flatMap((r) => r.json))) as {
583 messageId?: string;
584 error?: string;
585 message?: string;
586 };
587
588 expect(response.error).toBeUndefined();
589 expect(response.messageId).toBeTruthy();
590 }),
591 );
592 });
593
594 describe("GetMessageInsights", () => {
595 const insights = (messageId?: string) =>
596 send(
597 HttpClientRequest.get(
598 `${baseUrl}/message-insights${
599 messageId ? `?messageId=${encodeURIComponent(messageId)}` : ""
600 }`,
601 ),
602 ).pipe(Effect.flatMap((r) => r.json)) as Effect.Effect<
603 { messageId?: string; error?: string },
604 any,
605 any
606 >;
607
608 test.provider(
609 "a well-formed but unknown message id is rejected with a typed tag",
610 (_stack) =>
611 Effect.gen(function* () {
612 const response = yield* insights();
613
614 // No real send backs the fabricated MessageId, so SES rejects it —
615 // NotFoundException, or BadRequestException when VDM is disabled on
616 // the account. AccessDenied would mean ses:GetMessageInsights never
617 // reached the role.
618 expect(response.error).not.toBe("AccessDeniedException");
619 expect(REQUEST_TAGS).toContain(response.error);
620 expect(response.messageId).toBeUndefined();
621 if (VDM_ENABLED) expect(response.error).toBe("NotFoundException");
622 }),
623 );
624
625 test.provider.skipIf(!VDM_ENABLED)(
626 "a malformed message id is rejected with BadRequestException (AWS_TEST_SES_VDM)",
627 (_stack) =>
628 Effect.gen(function* () {
629 // Only reachable with VDM enabled. Without it SES short-circuits
630 // every GetMessageInsights call with NotFoundException ("To use this
631 // feature you must enable Virtual Deliverability Manager") before it
632 // ever looks at the id, so malformed and well-formed ids are
633 // indistinguishable on a VDM-less account.
634 const response = yield* insights("not-a-message-id");
635
636 expect(response.error).not.toBe("AccessDeniedException");
637 expect(response.error).toBe("BadRequestException");
638 expect(response.messageId).toBeUndefined();
639 }),
640 );
641 });
642
643 describe("BatchGetMetricData", () => {
644 const metricData = (partialDay = false) =>
645 send(
646 HttpClientRequest.post(
647 `${baseUrl}/metric-data${partialDay ? "?partialDay=1" : ""}`,
648 ),
649 ).pipe(Effect.flatMap((r) => r.json)) as Effect.Effect<
650 { results?: number; error?: string },
651 any,
652 any
653 >;
654
655 test.provider(
656 "a midnight-aligned window reaches SES through the binding",
657 (_stack) =>
658 Effect.gen(function* () {
659 const response = yield* metricData();
660
661 // With VDM enabled SES returns a (possibly empty) Results array;
662 // without it the account is rejected with BadRequestException. Both
663 // prove the IAM action and payload marshalling are wired — but
664 // neither may be an authorization failure.
665 expect(response.error).not.toBe("AccessDeniedException");
666 if (response.error === undefined) {
667 expect(typeof response.results).toBe("number");
668 } else {
669 expect(REQUEST_TAGS).toContain(response.error);
670 }
671 }),
672 );
673
674 test.provider.skipIf(!VDM_ENABLED)(
675 "returns a metric series on a VDM-enabled account (AWS_TEST_SES_VDM)",
676 (_stack) =>
677 Effect.gen(function* () {
678 const response = yield* metricData();
679 expect(response.error).toBeUndefined();
680 expect(typeof response.results).toBe("number");
681 }),
682 );
683
684 test.provider.skipIf(!VDM_ENABLED)(
685 "a partial-day window is rejected with BadRequestException (AWS_TEST_SES_VDM)",
686 (_stack) =>
687 Effect.gen(function* () {
688 // SES requires daily-aggregated queries to run midnight-to-midnight
689 // UTC, which pins that the binding forwards the timestamps it is
690 // given. Only reachable with VDM enabled: without it SES rejects
691 // every BatchGetMetricData call with NotFoundException before
692 // validating the window.
693 const response = yield* metricData(true);
694
695 expect(response.error).not.toBe("AccessDeniedException");
696 expect(response.error).toBe("BadRequestException");
697 }),
698 );
699 });
700
701 describe("GetDomainStatisticsReport", () => {
702 const domainStatistics = (domain?: string) =>
703 send(
704 HttpClientRequest.get(
705 `${baseUrl}/domain-statistics${
706 domain ? `?domain=${encodeURIComponent(domain)}` : ""
707 }`,
708 ),
709 ).pipe(Effect.flatMap((r) => r.json)) as Effect.Effect<
710 { days?: number; error?: string },
711 any,
712 any
713 >;
714
715 test.provider(
716 "requesting a domain deliverability report reaches SES through the binding",
717 (_stack) =>
718 Effect.gen(function* () {
719 const response = yield* domainStatistics();
720
721 // The deliverability dashboard is a paid subscription; without it
722 // SES rejects the request with a typed tag. Either way the call must
723 // have been authorized.
724 expect(response.error).not.toBe("AccessDeniedException");
725 if (response.error === undefined) {
726 expect(typeof response.days).toBe("number");
727 } else {
728 expect(REQUEST_TAGS).toContain(response.error);
729 }
730 }),
731 );
732
733 test.provider(
734 "a domain the account does not own is rejected with a typed tag",
735 (_stack) =>
736 Effect.gen(function* () {
737 const response = yield* domainStatistics(
738 "not-our-domain.alchemy-test.example.com",
739 );
740
741 expect(response.error).not.toBe("AccessDeniedException");
742 expect(REQUEST_TAGS).toContain(response.error);
743 expect(response.days).toBeUndefined();
744 }),
745 );
746 });
747
748 describe("GetBlacklistReports", () => {
749 const blacklistReports = (ip?: string) =>
750 send(
751 HttpClientRequest.get(
752 `${baseUrl}/blacklist-reports${
753 ip ? `?ip=${encodeURIComponent(ip)}` : ""
754 }`,
755 ),
756 ).pipe(Effect.flatMap((r) => r.json)) as Effect.Effect<
757 { ips?: string[]; error?: string },
758 any,
759 any
760 >;
761
762 // This binding has no request-rejection path to pin: SES needs no
763 // subscription for it and validates nothing about the items it is given.
764 // A garbage item (`not-an-ip-address`) returns 200 with an empty
765 // BlacklistReport exactly like a well-formed IP the account does not own,
766 // so a "malformed input" test here would assert the same thing as the
767 // success test. One unconditional test is the honest coverage.
768 test.provider(
769 "returns a blacklist report for the requested IPs",
770 (_stack) =>
771 Effect.gen(function* () {
772 const response = yield* blacklistReports();
773
774 expect(response.error).toBeUndefined();
775 expect(Array.isArray(response.ips)).toBe(true);
776 }),
777 );
778 });
779 },
780);