1import * as AWS from "@/AWS";
2import { ConfigurationSet, Tenant, TenantResourceAssociation } from "@/AWS/SES";
3import * as Test from "@/Test/Alchemy";
4import * as sesv2 from "@distilled.cloud/aws/sesv2";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const associatedArns = (tenantName: string) =>
13 sesv2.listTenantResources.pages({ TenantName: tenantName }).pipe(
14 Stream.runCollect,
15 Effect.map((pages) =>
16 Array.from(pages)
17 .flatMap((page) => page.TenantResources ?? [])
18 .flatMap((resource) =>
19 resource.ResourceArn ? [resource.ResourceArn] : [],
20 ),
21 ),
22 );
23
24const isAssociated = (tenantName: string, resourceArn: string) =>
25 associatedArns(tenantName).pipe(
26 Effect.map((arns) => arns.includes(resourceArn)),
27 );
28
29test.provider(
30 "tenant resource association lifecycle: associate a config set, verify, delete",
31 (stack) =>
32 Effect.gen(function* () {
33 yield* stack.destroy();
34
35 const { tenant, configSet } = yield* stack.deploy(
36 Effect.gen(function* () {
37 const tenant = yield* Tenant("Customer", {});
38 const configSet = yield* ConfigurationSet("Config", {});
39 yield* TenantResourceAssociation("Link", {
40 tenantName: tenant.tenantName,
41 resourceArn: configSet.configurationSetArn,
42 });
43 return { tenant, configSet };
44 }),
45 );
46
47 // out-of-band verification via distilled. The association is eventually
48 // consistent, so poll the SUCCESS value — `Effect.retry`'s `while` reads
49 // the error channel and would never see the boolean.
50 const found = yield* isAssociated(
51 tenant.tenantName,
52 configSet.configurationSetArn,
53 ).pipe(
54 Effect.repeat({
55 schedule: Schedule.spaced("1 second"),
56 until: (associated) => associated,
57 times: 10,
58 }),
59 );
60 expect(found).toBe(true);
61
62 yield* stack.destroy();
63
64 // The tenant and its associations are gone after destroy; listing a
65 // deleted tenant surfaces NotFoundException, treated as "not associated".
66 const gone = yield* isAssociated(
67 tenant.tenantName,
68 configSet.configurationSetArn,
69 ).pipe(Effect.catchTag("NotFoundException", () => Effect.succeed(false)));
70 expect(gone).toBe(false);
71 }),
72 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
73);
74
75test.provider(
76 "changing the resource ARN replaces the association",
77 (stack) =>
78 Effect.gen(function* () {
79 yield* stack.destroy();
80
81 const { tenant, first, second } = yield* stack.deploy(
82 Effect.gen(function* () {
83 const tenant = yield* Tenant("Customer", {});
84 const first = yield* ConfigurationSet("ConfigA", {});
85 const second = yield* ConfigurationSet("ConfigB", {});
86 yield* TenantResourceAssociation("Link", {
87 tenantName: tenant.tenantName,
88 resourceArn: first.configurationSetArn,
89 });
90 return { tenant, first, second };
91 }),
92 );
93
94 // Re-point the association at the second config set. Both config sets
95 // stay deployed across the replacement so the engine does not deadlock
96 // removing a dependency during a replace.
97 yield* stack.deploy(
98 Effect.gen(function* () {
99 const tenant = yield* Tenant("Customer", {});
100 yield* ConfigurationSet("ConfigA", {});
101 const second = yield* ConfigurationSet("ConfigB", {});
102 yield* TenantResourceAssociation("Link", {
103 tenantName: tenant.tenantName,
104 resourceArn: second.configurationSetArn,
105 });
106 return tenant;
107 }),
108 );
109
110 const arns = yield* associatedArns(tenant.tenantName);
111 expect(arns).toContain(second.configurationSetArn);
112 // A replacement must DROP the old link, not leave both associated.
113 expect(arns).not.toContain(first.configurationSetArn);
114
115 // Drop the association in its own step before tearing the stack down.
116 // The replacement leaves both configuration sets `noop`, and a `noop`
117 // resource never re-persists its downstream edges, so destroy orders
118 // against the PRE-replacement target: `ConfigB` and `Link` delete
119 // concurrently and SES rejects the configuration-set delete with "Cannot
120 // delete <arn> because it has tenant associations". Removing the
121 // association first keeps this test about replacement rather than about
122 // that bug — see #979.
123 yield* stack.deploy(
124 Effect.gen(function* () {
125 // Same logical id as the deploys above — renaming it here would
126 // replace the tenant instead of only dropping the association.
127 yield* Tenant("Customer", {});
128 yield* ConfigurationSet("ConfigA", {});
129 yield* ConfigurationSet("ConfigB", {});
130 }),
131 );
132
133 yield* stack.destroy();
134 }),
135 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
136);