1import * as AWS from "@/AWS";
2import type { PolicyDocument } from "@/AWS/IAM/Policy.ts";
3import { EmailIdentity, EmailIdentityPolicy } from "@/AWS/SES";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as sesv2 from "@distilled.cloud/aws/sesv2";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const DOMAIN = "policy.alchemy-test.example.com";
15
16// SES validates the policy document on write: Resource must be the identity's
17// own ARN, not a wildcard ("BadRequestException: Invalid ARN: ARNs must start
18// with 'arn:': *").
19const allowPolicy = (sid: string, identityArn: string): PolicyDocument => ({
20 Version: "2012-10-17",
21 Statement: [
22 {
23 Sid: sid,
24 Effect: "Allow",
25 Principal: { AWS: "*" },
26 Action: ["ses:SendEmail"],
27 Resource: identityArn,
28 },
29 ],
30});
31
32class PolicyStillExists extends Data.TaggedError("PolicyStillExists")<{
33 readonly emailIdentity: string;
34 readonly policyName: string;
35}> {}
36
37const assertPolicyDeleted = (emailIdentity: string, policyName: string) =>
38 sesv2.getEmailIdentityPolicies({ EmailIdentity: emailIdentity }).pipe(
39 Effect.flatMap((res) =>
40 (res.Policies ?? {})[policyName] !== undefined
41 ? Effect.fail(new PolicyStillExists({ emailIdentity, policyName }))
42 : Effect.void,
43 ),
44 // A deleted identity takes its policies with it.
45 Effect.catchTag("NotFoundException", () => Effect.void),
46 Effect.retry({
47 while: (e) => e._tag === "PolicyStillExists",
48 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
49 }),
50 );
51
52test.provider(
53 "identity policy lifecycle: create, update document, delete",
54 (stack) =>
55 Effect.gen(function* () {
56 yield* stack.destroy();
57
58 const { identity, policy } = yield* stack.deploy(
59 Effect.gen(function* () {
60 const identity = yield* EmailIdentity("PolicyIdentity", {
61 emailIdentity: DOMAIN,
62 });
63 const policy = yield* EmailIdentityPolicy("Authz", {
64 emailIdentity: identity.emailIdentity,
65 policy: Output.all(identity.identityArn).pipe(
66 Output.map(([arn]) => allowPolicy("AllowSend", arn)),
67 ),
68 });
69 return { identity, policy };
70 }),
71 );
72
73 expect(policy.emailIdentity).toBe(identity.emailIdentity);
74 expect(policy.policyName).toBeDefined();
75
76 // out-of-band verification via distilled
77 const observed = yield* sesv2.getEmailIdentityPolicies({
78 EmailIdentity: identity.emailIdentity,
79 });
80 expect(observed.Policies?.[policy.policyName]).toContain("AllowSend");
81
82 // update the policy document in place (identity stays deployed)
83 yield* stack.deploy(
84 Effect.gen(function* () {
85 const identity = yield* EmailIdentity("PolicyIdentity", {
86 emailIdentity: DOMAIN,
87 });
88 yield* EmailIdentityPolicy("Authz", {
89 emailIdentity: identity.emailIdentity,
90 policy: Output.all(identity.identityArn).pipe(
91 Output.map(([arn]) => allowPolicy("AllowSendUpdated", arn)),
92 ),
93 });
94 }),
95 );
96 const updated = yield* sesv2.getEmailIdentityPolicies({
97 EmailIdentity: identity.emailIdentity,
98 });
99 expect(updated.Policies?.[policy.policyName]).toContain(
100 "AllowSendUpdated",
101 );
102
103 yield* stack.destroy();
104 yield* assertPolicyDeleted(identity.emailIdentity, policy.policyName);
105 }),
106 {
107 tags: ["provider:aws", "provider:aws:iam", "provider:aws:ses", "live"],
108 timeout: 120_000,
109 },
110);
111
112test.provider(
113 "renaming the policy replaces it on the same identity",
114 (stack) =>
115 Effect.gen(function* () {
116 yield* stack.destroy();
117
118 // The identity stays deployed across the replacement so the engine never
119 // removes a dependency of the resource being replaced.
120 const first = yield* stack.deploy(
121 Effect.gen(function* () {
122 const identity = yield* EmailIdentity("RenamePolicyIdentity", {
123 emailIdentity: DOMAIN,
124 });
125 const policy = yield* EmailIdentityPolicy("RenamePolicy", {
126 emailIdentity: identity.emailIdentity,
127 policyName: "alchemy-test-policy-a",
128 policy: Output.all(identity.identityArn).pipe(
129 Output.map(([arn]) => allowPolicy("A", arn)),
130 ),
131 });
132 return {
133 emailIdentity: identity.emailIdentity,
134 policyName: policy.policyName,
135 };
136 }),
137 );
138 expect(first.policyName).toBe("alchemy-test-policy-a");
139
140 const second = yield* stack.deploy(
141 Effect.gen(function* () {
142 const identity = yield* EmailIdentity("RenamePolicyIdentity", {
143 emailIdentity: DOMAIN,
144 });
145 const policy = yield* EmailIdentityPolicy("RenamePolicy", {
146 emailIdentity: identity.emailIdentity,
147 policyName: "alchemy-test-policy-b",
148 policy: Output.all(identity.identityArn).pipe(
149 Output.map(([arn]) => allowPolicy("B", arn)),
150 ),
151 });
152 return {
153 emailIdentity: identity.emailIdentity,
154 policyName: policy.policyName,
155 };
156 }),
157 );
158 expect(second.policyName).toBe("alchemy-test-policy-b");
159 yield* assertPolicyDeleted(first.emailIdentity, "alchemy-test-policy-a");
160
161 yield* stack.destroy();
162 yield* assertPolicyDeleted(second.emailIdentity, "alchemy-test-policy-b");
163 }),
164 {
165 tags: ["provider:aws", "provider:aws:iam", "provider:aws:ses", "live"],
166 timeout: 120_000,
167 },
168);