1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy.ts";
2import * as AWS from "@/AWS";
3import { Tenant } from "@/AWS/SES";
4import * as Test from "@/Test/Alchemy";
5import * as sesv2 from "@distilled.cloud/aws/sesv2";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13class TenantStillExists extends Data.TaggedError("TenantStillExists")<{
14 readonly name: string;
15}> {}
16
17const getTenant = (name: string) =>
18 sesv2.getTenant({ TenantName: name }).pipe(
19 Effect.map((response) => response.Tenant),
20 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
21 );
22
23const assertTenantDeleted = (name: string) =>
24 getTenant(name).pipe(
25 Effect.flatMap((found) =>
26 found ? Effect.fail(new TenantStillExists({ name })) : Effect.void,
27 ),
28 Effect.retry({
29 while: (e) => e._tag === "TenantStillExists",
30 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
31 }),
32 );
33
34test.provider(
35 "tenant lifecycle: create, update suppression/tags, delete",
36 (stack) =>
37 Effect.gen(function* () {
38 yield* stack.destroy();
39
40 const tenant = yield* stack.deploy(
41 Effect.gen(function* () {
42 return yield* Tenant("CustomerA", {
43 suppression: { reasons: ["BOUNCE"], scope: "TENANT" },
44 tags: { Environment: "test" },
45 });
46 }),
47 );
48
49 expect(tenant.tenantName).toBeDefined();
50 expect(tenant.tenantId).toBeDefined();
51 expect(tenant.tenantArn).toContain(":tenant");
52
53 // out-of-band verification via distilled
54 const observed = yield* getTenant(tenant.tenantName);
55 expect(observed?.SuppressionAttributes?.SuppressedReasons).toEqual([
56 "BOUNCE",
57 ]);
58 const tags = Object.fromEntries(
59 (observed?.Tags ?? []).map((t) => [t.Key, t.Value]),
60 );
61 expect(tags.Environment).toBe("test");
62 expect(tags["alchemy::id"]).toBe("CustomerA");
63
64 // update suppression reasons and add a tag
65 yield* stack.deploy(
66 Effect.gen(function* () {
67 return yield* Tenant("CustomerA", {
68 suppression: { reasons: ["BOUNCE", "COMPLAINT"], scope: "TENANT" },
69 tags: { Environment: "test", Extra: "1" },
70 });
71 }),
72 );
73 const updated = yield* getTenant(tenant.tenantName);
74 expect(
75 [...(updated?.SuppressionAttributes?.SuppressedReasons ?? [])].sort(),
76 ).toEqual(["BOUNCE", "COMPLAINT"]);
77 const updatedTags = Object.fromEntries(
78 (updated?.Tags ?? []).map((t) => [t.Key, t.Value]),
79 );
80 expect(updatedTags.Extra).toBe("1");
81
82 yield* stack.destroy();
83 yield* assertTenantDeleted(tenant.tenantName);
84 }),
85 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
86);
87
88test.provider(
89 "custom name replaces on rename",
90 (stack) =>
91 Effect.gen(function* () {
92 yield* stack.destroy();
93
94 const first = yield* stack.deploy(
95 Effect.gen(function* () {
96 return yield* Tenant("Named", {
97 tenantName: "alchemy-test-tenant-a",
98 });
99 }),
100 );
101 expect(first.tenantName).toBe("alchemy-test-tenant-a");
102
103 const second = yield* stack.deploy(
104 Effect.gen(function* () {
105 return yield* Tenant("Named", {
106 tenantName: "alchemy-test-tenant-b",
107 });
108 }),
109 );
110 expect(second.tenantName).toBe("alchemy-test-tenant-b");
111 yield* assertTenantDeleted("alchemy-test-tenant-a");
112
113 yield* stack.destroy();
114 yield* assertTenantDeleted("alchemy-test-tenant-b");
115 }),
116 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
117);
118
119// Second adoption-gate proof, on a different taggable resource than the
120// dedicated IP pool. Tenants are free and create/delete in seconds, and
121// unlike ContactList they are not an account singleton, so a foreign tenant
122// can coexist with the other tests in this file.
123const FOREIGN_TENANT = "alchemy-test-foreign-tenant";
124
125const deleteTenantIfExists = (name: string) =>
126 sesv2
127 .deleteTenant({ TenantName: name })
128 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
129
130test.provider(
131 "a tenant without alchemy tags is not adopted without --adopt",
132 (stack) =>
133 Effect.gen(function* () {
134 yield* stack.destroy();
135 // Idempotent pre-clean: reclaim a leftover from an interrupted run.
136 yield* deleteTenantIfExists(FOREIGN_TENANT);
137
138 // Someone else's tenant: same name, no alchemy tags.
139 yield* sesv2.createTenant({ TenantName: FOREIGN_TENANT });
140
141 const error = yield* stack
142 .deploy(
143 Effect.gen(function* () {
144 return yield* Tenant("ForeignTenant", {
145 tenantName: FOREIGN_TENANT,
146 });
147 }),
148 )
149 .pipe(Effect.flip);
150 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
151
152 // The refusal touched nothing.
153 const untouched = yield* getTenant(FOREIGN_TENANT);
154 expect(untouched?.TenantName).toBe(FOREIGN_TENANT);
155
156 // adopt(true) takes it over and brands it, so it converges thereafter.
157 const adopted = yield* stack.deploy(
158 Effect.gen(function* () {
159 return yield* Tenant("ForeignTenant", {
160 tenantName: FOREIGN_TENANT,
161 }).pipe(adopt(true));
162 }),
163 );
164 expect(adopted.tenantName).toBe(FOREIGN_TENANT);
165
166 const branded = yield* getTenant(FOREIGN_TENANT);
167 const tags = Object.fromEntries(
168 (branded?.Tags ?? []).map((t) => [t.Key, t.Value]),
169 );
170 expect(tags["alchemy::id"]).toBe("ForeignTenant");
171
172 yield* stack.destroy();
173 yield* assertTenantDeleted(FOREIGN_TENANT);
174 }),
175 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
176);