1import * as AWS from "@/AWS";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as organizations from "@distilled.cloud/aws/organizations";
5import * as Stream from "effect/Stream";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Tree-structured enumeration: `list()` walks the org tree (listRoots ->
12// recursive listOrganizationalUnitsForParent) and hydrates each OU into the
13// exact `read` Attributes shape. This runs read-only — it neither creates nor
14// deletes any organizational unit. When the account isn't an organization
15// management account, the typed `AWSOrganizationsNotInUseException` /
16// `AccessDeniedException` degrade to `[]`.
17test.provider(
18 "list enumerates the organizational units",
19 (stack) =>
20 Effect.gen(function* () {
21 const provider = yield* Provider.findProvider(
22 AWS.Organizations.OrganizationalUnit,
23 );
24 const all = yield* provider.list();
25
26 // 0 when the account isn't a management account (or has no OUs); otherwise
27 // every discovered OU. Never negative.
28 expect(all.length).toBeGreaterThanOrEqual(0);
29
30 // Each entry carries the well-typed Attributes shape that `read` produces.
31 for (const ou of all) {
32 expect(typeof ou.ouId).toBe("string");
33 expect(ou.ouId.length).toBeGreaterThan(0);
34 expect(typeof ou.ouArn).toBe("string");
35 expect(ou.ouArn.startsWith("arn:aws:organizations::")).toBe(true);
36 expect(typeof ou.name).toBe("string");
37 expect(ou.name.length).toBeGreaterThan(0);
38 expect(typeof ou.tags).toBe("object");
39 }
40
41 yield* stack.destroy();
42 }),
43 { tags: ["provider:aws", "provider:aws:organizations", "live"] },
44);
45
46test.provider(
47 "restores ownership tags removed from AWS",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 const roots = yield* organizations.listRoots({});
53 const parentId = roots.Roots?.[0]?.Id;
54 if (!parentId) {
55 return yield* Effect.fail(
56 new Error("An AWS Organizations root is required"),
57 );
58 }
59
60 const makeOU = (phase: string) =>
61 AWS.Organizations.OrganizationalUnit("TagDriftOU", {
62 parentId,
63 tags: { phase },
64 });
65 const readTags = (resourceId: string) =>
66 organizations.listTagsForResource
67 .items({ ResourceId: resourceId })
68 .pipe(
69 Stream.runCollect,
70 Effect.map((tags) =>
71 Object.fromEntries(tags.map((tag) => [tag.Key!, tag.Value!])),
72 ),
73 );
74
75 yield* Effect.gen(function* () {
76 const created = yield* stack.deploy(makeOU("created"));
77 const originalTags = yield* readTags(created.ouId);
78 const ownershipKeys = [
79 "alchemy::stack",
80 "alchemy::stage",
81 "alchemy::id",
82 ];
83 for (const key of ownershipKeys) {
84 expect(originalTags[key]).toBeDefined();
85 }
86
87 yield* organizations.untagResource({
88 ResourceId: created.ouId,
89 TagKeys: ownershipKeys,
90 });
91 expect(yield* readTags(created.ouId)).toEqual({ phase: "created" });
92
93 // Changing a user tag forces reconciliation rather than a no-op deploy.
94 const repaired = yield* stack.deploy(makeOU("repaired"));
95 expect(repaired.ouId).toEqual(created.ouId);
96 const expected = { ...originalTags, phase: "repaired" };
97 expect(yield* readTags(created.ouId)).toEqual(expected);
98 expect(repaired.tags).toEqual(expected);
99
100 // Force a second reconciliation and verify ownership tags stay intact.
101 const converged = yield* stack.deploy(makeOU("converged"));
102 const finalTags = { ...originalTags, phase: "converged" };
103 expect(converged.ouId).toEqual(created.ouId);
104 expect(yield* readTags(created.ouId)).toEqual(finalTags);
105 expect(converged.tags).toEqual(finalTags);
106 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie)));
107 }),
108 {
109 tags: ["provider:aws", "provider:aws:organizations", "live"],
110 timeout: 120_000,
111 },
112);