1import * as AWS from "@/AWS";
2import {
3 ConditionalForwarder,
4 Directory,
5 EventTopic,
6} from "@/AWS/DirectoryService";
7import { Topic } from "@/AWS/SNS";
8import * as Test from "@/Test/Alchemy";
9import * as ds from "@distilled.cloud/aws/directory-service";
10import * as EC2 from "@distilled.cloud/aws/ec2";
11import { expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Redacted from "effect/Redacted";
14import * as Schedule from "effect/Schedule";
15import { getDefaultVpc } from "../DefaultVpc.ts";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19// Ungated typed-error probes: prove the distilled error union carries the
20// not-found tag this provider's read/delete paths depend on. Runs in every
21// CI pass at near-zero cost, unlike the gated lifecycle below.
22test.provider(
23 "describeDirectories on a nonexistent directory fails with EntityDoesNotExistException",
24 () =>
25 Effect.gen(function* () {
26 const error = yield* Effect.flip(
27 ds.describeDirectories({ DirectoryIds: ["d-1234567890"] }),
28 );
29 expect(error._tag).toBe("EntityDoesNotExistException");
30 }),
31 { tags: ["provider:aws", "provider:aws:directoryservice", "live"] },
32);
33
34test.provider(
35 "describeConditionalForwarders on a nonexistent directory fails with EntityDoesNotExistException",
36 () =>
37 Effect.gen(function* () {
38 const error = yield* Effect.flip(
39 ds.describeConditionalForwarders({
40 DirectoryId: "d-1234567890",
41 RemoteDomainNames: ["partner.alchemy-test.internal"],
42 }),
43 );
44 expect(error._tag).toBe("EntityDoesNotExistException");
45 }),
46 { tags: ["provider:aws", "provider:aws:directoryservice", "live"] },
47);
48
49test.provider(
50 "describeEventTopics on a nonexistent directory fails with EntityDoesNotExistException",
51 () =>
52 Effect.gen(function* () {
53 const error = yield* Effect.flip(
54 ds.describeEventTopics({ DirectoryId: "d-1234567890" }),
55 );
56 expect(error._tag).toBe("EntityDoesNotExistException");
57 }),
58 { tags: ["provider:aws", "provider:aws:directoryservice", "live"] },
59);
60
61test.provider(
62 "registerEventTopic on a nonexistent directory fails with EntityDoesNotExistException",
63 () =>
64 Effect.gen(function* () {
65 const error = yield* Effect.flip(
66 ds.registerEventTopic({
67 DirectoryId: "d-1234567890",
68 TopicName: "alchemy-directory-service-missing",
69 }),
70 );
71 expect(error._tag).toBe("EntityDoesNotExistException");
72 }),
73 { tags: ["provider:aws", "provider:aws:directoryservice", "live"] },
74);
75
76// Resolve the default VPC and two default-for-AZ subnets in DIFFERENT AZs —
77// Directory Service launches one domain controller per subnet and requires
78// exactly two subnets in distinct Availability Zones.
79const defaultNetwork = Effect.gen(function* () {
80 const vpc = yield* getDefaultVpc;
81 const subnets = yield* EC2.describeSubnets({
82 Filters: [
83 { Name: "vpc-id", Values: [vpc.vpcId] },
84 { Name: "default-for-az", Values: ["true"] },
85 ],
86 });
87 const byAz = new Map<string, string>();
88 for (const subnet of subnets.Subnets ?? []) {
89 if (
90 subnet.SubnetId !== undefined &&
91 subnet.AvailabilityZone !== undefined &&
92 // Not every AZ supports every service — stick to the region's first
93 // three AZs (suffix a/b/c).
94 /[abc]$/.test(subnet.AvailabilityZone) &&
95 !byAz.has(subnet.AvailabilityZone)
96 ) {
97 byAz.set(subnet.AvailabilityZone, subnet.SubnetId);
98 }
99 }
100 const subnetIds = [...byAz.entries()]
101 .sort((l, r) => l[0].localeCompare(r[0]))
102 .map(([, id]) => id)
103 .slice(0, 2);
104 if (subnetIds.length < 2) {
105 return yield* Effect.die(
106 new Error("default VPC is missing subnets in two distinct AZs"),
107 );
108 }
109 return { vpcId: vpc.vpcId, subnetIds };
110});
111
112// Deletion is verified as INITIATED (stage `Deleting`, irreversible) or
113// fully gone. Full disappearance takes several more minutes server-side.
114const assertDirectoryDeleting = (directoryId: string) =>
115 Effect.gen(function* () {
116 const stage = yield* ds
117 .describeDirectories({ DirectoryIds: [directoryId] })
118 .pipe(
119 Effect.map(
120 (r) => r.DirectoryDescriptions?.[0]?.Stage ?? ("gone" as const),
121 ),
122 Effect.catchTag("EntityDoesNotExistException", () =>
123 Effect.succeed("gone" as const),
124 ),
125 );
126 if (stage !== "gone" && stage !== "Deleting" && stage !== "Deleted") {
127 return yield* Effect.fail(
128 new Error(`directory '${directoryId}' still exists (stage: ${stage})`),
129 );
130 }
131 }).pipe(
132 Effect.retry({
133 schedule: Schedule.max([
134 Schedule.fixed("10 seconds"),
135 Schedule.recurs(18),
136 ]),
137 }),
138 );
139
140// A Simple AD directory takes ~10 minutes to provision (Microsoft AD takes
141// 20-40) and bills hourly while it exists. The full lifecycle is gated
142// behind AWS_TEST_DIRECTORY=1 and always destroys what it created.
143test.provider.skipIf(!process.env.AWS_TEST_DIRECTORY)(
144 "create Simple AD directory, verify, update tags, destroy",
145 (stack) =>
146 Effect.gen(function* () {
147 yield* stack.destroy();
148
149 const network = yield* defaultNetwork;
150 const props = {
151 name: "corp.alchemy-test.internal",
152 password: Redacted.make("AlchemyTest123!"),
153 size: "Small" as const,
154 description: "alchemy directory-service test",
155 vpcId: network.vpcId,
156 subnetIds: network.subnetIds,
157 };
158
159 const buildStack = (tags: Record<string, string>) =>
160 Effect.gen(function* () {
161 const directory = yield* Directory("Corp", { ...props, tags });
162 // Directory Service's native event mechanism: publish status
163 // changes to an SNS topic.
164 const topic = yield* Topic("Status", {});
165 const eventTopic = yield* EventTopic("StatusTopic", {
166 directoryId: directory.directoryId,
167 topicName: topic.topicName,
168 });
169 return { directory, eventTopic };
170 });
171
172 const { directory, eventTopic } = yield* stack.deploy(
173 buildStack({ fixture: "directory-service" }),
174 );
175
176 expect(directory.directoryId).toMatch(/^d-/);
177 expect(directory.directoryName).toBe("corp.alchemy-test.internal");
178 expect(directory.type).toBe("SimpleAD");
179 expect(directory.stage).toBe("Active");
180 expect(directory.size).toBe("Small");
181 expect(directory.dnsIpAddrs.length).toBe(2);
182 expect(directory.vpcId).toBe(network.vpcId);
183 expect([...directory.subnetIds].sort()).toEqual(
184 [...network.subnetIds].sort(),
185 );
186 expect(directory.tags.fixture).toBe("directory-service");
187 expect(directory.tags["alchemy::id"]).toBeDefined();
188
189 // Out-of-band verification via distilled.
190 const described = yield* ds.describeDirectories({
191 DirectoryIds: [directory.directoryId],
192 });
193 const observed = described.DirectoryDescriptions?.[0];
194 expect(observed?.Stage).toBe("Active");
195 expect(observed?.Type).toBe("SimpleAD");
196 expect(observed?.Name).toBe("corp.alchemy-test.internal");
197
198 // The EventTopic association registered the directory as a publisher
199 // to the SNS topic — verify out-of-band via distilled.
200 expect(eventTopic.directoryId).toBe(directory.directoryId);
201 expect(eventTopic.topicArn).toBeDefined();
202 expect(eventTopic.status).toBe("Registered");
203 const topics = yield* ds.describeEventTopics({
204 DirectoryId: directory.directoryId,
205 });
206 expect(topics.EventTopics?.map((topic) => topic.TopicName)).toContain(
207 eventTopic.topicName,
208 );
209
210 // Tags mutate in place — a second deploy must keep the SAME directory
211 // id (no replacement) and converge the tag set.
212 const { directory: updated } = yield* stack.deploy(
213 buildStack({ fixture: "directory-service", wave: "2" }),
214 );
215 expect(updated.directoryId).toBe(directory.directoryId);
216 expect(updated.tags.wave).toBe("2");
217
218 // Destroy immediately — directories bill while they exist — and
219 // verify deletion was initiated out-of-band.
220 yield* stack.destroy();
221 yield* assertDirectoryDeleting(directory.directoryId);
222 }),
223 // directory create (~10 min) + tag-sync deploy + delete initiation.
224 {
225 tags: [
226 "provider:aws",
227 "provider:aws:directoryservice",
228 "provider:aws:ec2",
229 "provider:aws:sns",
230 "live",
231 ],
232 timeout: 2_400_000,
233 },
234);
235
236// Conditional forwarders require an AWS Managed Microsoft AD directory
237// (Simple AD does not support them), which takes 20-40 minutes to provision.
238// Gated separately behind AWS_TEST_DIRECTORY_MSAD=1.
239test.provider.skipIf(!process.env.AWS_TEST_DIRECTORY_MSAD)(
240 "create Microsoft AD directory with conditional forwarder, update DNS, destroy",
241 (stack) =>
242 Effect.gen(function* () {
243 yield* stack.destroy();
244
245 const network = yield* defaultNetwork;
246 const directoryProps = {
247 type: "MicrosoftAD" as const,
248 name: "msad.alchemy-test.internal",
249 password: Redacted.make("AlchemyTest123!"),
250 edition: "Standard" as const,
251 vpcId: network.vpcId,
252 subnetIds: network.subnetIds,
253 };
254 const build = (dnsIpAddrs: string[]) =>
255 Effect.gen(function* () {
256 const directory = yield* Directory("MsAd", directoryProps);
257 const forwarder = yield* ConditionalForwarder("Partner", {
258 directoryId: directory.directoryId,
259 remoteDomainName: "partner.alchemy-test.internal",
260 dnsIpAddrs,
261 });
262 return { directory, forwarder };
263 });
264
265 const { directory, forwarder } = yield* stack.deploy(
266 build(["10.200.0.2"]),
267 );
268 expect(directory.type).toBe("MicrosoftAD");
269 expect(directory.stage).toBe("Active");
270 expect(forwarder.remoteDomainName).toBe("partner.alchemy-test.internal");
271 expect(forwarder.dnsIpAddrs).toEqual(["10.200.0.2"]);
272
273 // Out-of-band verification via distilled.
274 const observed = yield* ds.describeConditionalForwarders({
275 DirectoryId: directory.directoryId,
276 RemoteDomainNames: ["partner.alchemy-test.internal"],
277 });
278 expect(observed.ConditionalForwarders?.[0]?.DnsIpAddrs).toEqual([
279 "10.200.0.2",
280 ]);
281
282 // DNS addresses mutate in place — same directory, same forwarder.
283 const { forwarder: updated } = yield* stack.deploy(
284 build(["10.200.0.2", "10.200.1.2"]),
285 );
286 expect([...updated.dnsIpAddrs].sort()).toEqual([
287 "10.200.0.2",
288 "10.200.1.2",
289 ]);
290
291 yield* stack.destroy();
292 yield* assertDirectoryDeleting(directory.directoryId);
293 }),
294 // Microsoft AD create (20-40 min) + forwarder sync + delete initiation.
295 {
296 tags: [
297 "provider:aws",
298 "provider:aws:directoryservice",
299 "provider:aws:ec2",
300 "live",
301 ],
302 timeout: 3_600_000,
303 },
304);