1import * as AWS from "@/AWS";
2import {
3 normalizePolicyDocument,
4 type ServiceControlPolicyDocument,
5} from "@/AWS/IAM/Policy.ts";
6import { Policy } from "@/AWS/Organizations";
7import * as Provider from "@/Provider";
8import * as Test from "@/Test/Alchemy";
9import * as organizations from "@distilled.cloud/aws/organizations";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// `listPolicies` requires a `Filter` (policy type), so `list()` fans out across
16// every policy-type filter and hydrates each policy via `describePolicy` into the
17// exact `read` shape. This runs read-only: when the account is an org management
18// account, AWS-managed SCPs like `FullAWSAccess` appear; otherwise `list()`
19// degrades to `[]` via the typed `AWSOrganizationsNotInUseException` /
20// `AccessDeniedException` catches, so the assertions hold without deploying.
21test.provider(
22 "list enumerates organization policies",
23 (stack) =>
24 Effect.gen(function* () {
25 const provider = yield* Provider.findProvider(Policy);
26 const all = yield* provider.list();
27
28 expect(Array.isArray(all)).toBe(true);
29
30 for (const policy of all) {
31 expect(typeof policy.policyId).toBe("string");
32 expect(policy.policyId.length).toBeGreaterThan(0);
33 expect(typeof policy.policyArn).toBe("string");
34 expect(policy.policyArn.startsWith("arn:aws")).toBe(true);
35 expect(typeof policy.name).toBe("string");
36 expect(policy.document).toBeDefined();
37 expect(policy.tags).toBeDefined();
38 }
39
40 yield* stack.destroy();
41 }),
42 { tags: ["provider:aws", "provider:aws:organizations", "live"] },
43);
44
45// PolicyDocument adoption: a typed `ServiceControlPolicyDocument` deploys, the
46// stored SCP content round-trips (normalized comparison), and re-deploying the
47// identical document is clean — reconcile diffs
48// `normalizePolicyDocument(observed)` against
49// `normalizePolicyDocument(desired)` and skips `updatePolicy` on equivalence.
50//
51// `createPolicy`/`deletePolicy` require an org MANAGEMENT account, so gate
52// behind the same env var the other Organizations lifecycle tests use.
53test.provider.skipIf(!process.env.AWS_ORG_MANAGEMENT_ACCOUNT)(
54 "typed SCP document deploys and re-deploys clean",
55 (stack) =>
56 Effect.gen(function* () {
57 const policyName = "alchemy-test-org-policy-typed-scp";
58
59 // A prior interrupted run may have left an (untagged, hence unowned)
60 // SCP with our deterministic name behind — the adoption probe would
61 // then fail the create with `OwnedBySomeoneElse`. Clean it up
62 // out-of-band before deploying.
63 const leftovers = yield* organizations
64 .listPolicies({ Filter: "SERVICE_CONTROL_POLICY" })
65 .pipe(Effect.map((page) => page.Policies ?? []));
66 for (const leftover of leftovers) {
67 if (leftover.Name !== policyName || leftover.Id == null) continue;
68 yield* organizations
69 .deletePolicy({ PolicyId: leftover.Id })
70 .pipe(Effect.catchTag("PolicyNotFoundException", () => Effect.void));
71 }
72
73 // Key order is deliberately non-alphabetical so the no-op re-deploy
74 // exercises the canonicalizing comparison, not string equality.
75 const document: ServiceControlPolicyDocument = {
76 Version: "2012-10-17",
77 Statement: [
78 {
79 Sid: "AllowAll",
80 Effect: "Allow",
81 Resource: "*",
82 Action: ["*"],
83 },
84 ],
85 };
86
87 const deployPolicy = stack.deploy(
88 Effect.gen(function* () {
89 return yield* Policy("TypedScp", {
90 name: policyName,
91 type: "SERVICE_CONTROL_POLICY",
92 document,
93 });
94 }),
95 );
96
97 const created = yield* deployPolicy;
98 expect(created.policyId).toBeTruthy();
99 expect(created.type).toBe("SERVICE_CONTROL_POLICY");
100 // The typed attribute round-trips the typed document.
101 expect(normalizePolicyDocument(created.document)).toBe(
102 normalizePolicyDocument(document),
103 );
104
105 // Out-of-band verification via distilled: the stored content is
106 // equivalent to the typed document.
107 const described = yield* organizations.describePolicy({
108 PolicyId: created.policyId,
109 });
110 expect(described.Policy?.Content).toBeTruthy();
111 expect(normalizePolicyDocument(described.Policy?.Content ?? "")).toBe(
112 normalizePolicyDocument(document),
113 );
114
115 // Re-deploy the identical typed document — must be a clean no-op:
116 // same physical policy, equivalent content.
117 const redeployed = yield* deployPolicy;
118 expect(redeployed.policyId).toBe(created.policyId);
119 expect(redeployed.policyArn).toBe(created.policyArn);
120 expect(normalizePolicyDocument(redeployed.document)).toBe(
121 normalizePolicyDocument(document),
122 );
123
124 yield* stack.destroy();
125
126 // Typed wait-until-gone: the policy is deleted after destroy.
127 const gone = yield* organizations
128 .describePolicy({ PolicyId: created.policyId })
129 .pipe(
130 Effect.map(() => false),
131 Effect.catchTag("PolicyNotFoundException", () =>
132 Effect.succeed(true),
133 ),
134 );
135 expect(gone).toBe(true);
136 }),
137 {
138 tags: [
139 "provider:aws",
140 "provider:aws:iam",
141 "provider:aws:organizations",
142 "live",
143 ],
144 timeout: 240_000,
145 },
146);