1import type { IamAction } from "@/AWS/IAM/actions.generated.ts";
2import {
3 normalizePolicyDocument,
4 stringifyPolicyDocument,
5 type PolicyDocument,
6 type ServiceControlPolicyDocument,
7} from "@/AWS/IAM/Policy.ts";
8import { describe, expect, it } from "alchemy-test";
9
10const document: PolicyDocument = {
11 Version: "2012-10-17",
12 Statement: [
13 {
14 Sid: "ReadObjects",
15 Effect: "Allow",
16 Action: ["s3:GetObject", "s3:ListBucket"],
17 Resource: ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
18 Condition: {
19 StringEquals: { "aws:PrincipalOrgID": "o-example" },
20 },
21 },
22 ],
23};
24
25describe(
26 "stringifyPolicyDocument",
27 { tags: ["unit", "provider:aws", "provider:aws:iam", "local"] },
28 () => {
29 it("round-trips through JSON.parse", () => {
30 expect(JSON.parse(stringifyPolicyDocument(document))).toEqual(document);
31 });
32 },
33);
34
35describe(
36 "normalizePolicyDocument",
37 { tags: ["unit", "provider:aws", "provider:aws:iam", "local"] },
38 () => {
39 it("makes two equivalent documents with different key order equal", () => {
40 const reordered = {
41 Statement: [
42 {
43 Condition: {
44 StringEquals: { "aws:PrincipalOrgID": "o-example" },
45 },
46 Resource: ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
47 Action: ["s3:GetObject", "s3:ListBucket"],
48 Effect: "Allow",
49 Sid: "ReadObjects",
50 },
51 ],
52 Version: "2012-10-17",
53 };
54 expect(normalizePolicyDocument(reordered)).toBe(
55 normalizePolicyDocument(document),
56 );
57 });
58
59 it("treats a JSON string and the equivalent object identically", () => {
60 const json = JSON.stringify(document, null, 2);
61 expect(normalizePolicyDocument(json)).toBe(
62 normalizePolicyDocument(document),
63 );
64 });
65
66 it("handles the URL-encoded documents IAM returns", () => {
67 const encoded = encodeURIComponent(JSON.stringify(document));
68 expect(normalizePolicyDocument(encoded)).toBe(
69 normalizePolicyDocument(document),
70 );
71 });
72
73 it("is deterministic for the same document", () => {
74 expect(normalizePolicyDocument(document)).toBe(
75 normalizePolicyDocument(JSON.parse(stringifyPolicyDocument(document))),
76 );
77 });
78
79 it("returns unparseable strings unchanged so drift diffs still fire", () => {
80 expect(normalizePolicyDocument("not-json{")).toBe("not-json{");
81 });
82
83 it("treats singleton arrays and scalars as equivalent (AWS collapses them)", () => {
84 // The IAM grammar accepts `"x"` wherever `["x"]` is legal, and several
85 // services (e.g. Secrets Manager `GetResourcePolicy`) return the scalar
86 // form for a stored singleton array — the two must canonicalize equal or
87 // every re-deploy spuriously re-puts the policy.
88 const arrays: PolicyDocument = {
89 Version: "2012-10-17",
90 Statement: [
91 {
92 Effect: "Allow",
93 Principal: { AWS: ["arn:aws:iam::123456789012:root"] },
94 Action: ["secretsmanager:GetSecretValue"],
95 Resource: "*",
96 },
97 ],
98 };
99 const scalars = {
100 Version: "2012-10-17",
101 Statement: {
102 Effect: "Allow",
103 Principal: { AWS: "arn:aws:iam::123456789012:root" },
104 Action: "secretsmanager:GetSecretValue",
105 Resource: "*",
106 },
107 };
108 expect(normalizePolicyDocument(arrays)).toBe(
109 normalizePolicyDocument(scalars),
110 );
111 // Multi-element arrays are untouched (order still significant).
112 expect(normalizePolicyDocument(document)).toContain(
113 '"Action":["s3:GetObject","s3:ListBucket"]',
114 );
115 });
116 },
117);
118
119describe(
120 "IamAction",
121 { tags: ["unit", "provider:aws", "provider:aws:iam", "local"] },
122 () => {
123 it("accepts generated literals, wildcards, and arbitrary strings", () => {
124 // Compile-time assertions: generated ops autocomplete, `service:*`
125 // wildcards exist, and the `(string & {})` escape hatch never hard-breaks.
126 const actions: IamAction[] = [
127 "s3:GetObject",
128 "iam:CreateRole",
129 "states:StartExecution",
130 "cloudwatch:PutMetricData",
131 "dynamodb:*",
132 "some-future-service:SomeUnknownAction",
133 ];
134 expect(actions).toHaveLength(6);
135
136 // PolicyStatement.Action accepts both typed and plain-string arrays.
137 const typed: PolicyDocument = {
138 Version: "2012-10-17",
139 Statement: [
140 { Effect: "Allow", Action: ["lambda:InvokeFunction"], Resource: "*" },
141 ],
142 };
143 const plain: string[] = ["s3:GetObject"];
144 const fromStrings: PolicyDocument = {
145 Version: "2012-10-17",
146 Statement: [{ Effect: "Allow", Action: plain, Resource: "*" }],
147 };
148 expect(typed.Statement).toHaveLength(1);
149 expect(fromStrings.Statement).toHaveLength(1);
150 });
151 },
152);
153
154describe(
155 "ServiceControlPolicyDocument",
156 { tags: ["unit", "provider:aws", "provider:aws:iam", "local"] },
157 () => {
158 it("types the SCP-legal subset (no Principal) and normalizes like any doc", () => {
159 const scp: ServiceControlPolicyDocument = {
160 Version: "2012-10-17",
161 Statement: [
162 { Sid: "AllowAll", Effect: "Allow", Action: ["*"], Resource: "*" },
163 {
164 Sid: "DenyLeaveOrg",
165 Effect: "Deny",
166 Action: ["organizations:LeaveOrganization"],
167 Resource: "*",
168 Condition: {
169 StringNotEquals: { "aws:PrincipalOrgID": "o-example" },
170 },
171 },
172 ],
173 };
174 const illegal: ServiceControlPolicyDocument = {
175 Version: "2012-10-17",
176 Statement: [
177 {
178 Effect: "Deny",
179 Action: ["s3:*"],
180 Resource: "*",
181 // @ts-expect-error — SCP statements never carry a Principal
182 Principal: { AWS: "*" },
183 },
184 ],
185 };
186 expect(illegal).toBeDefined();
187 expect(normalizePolicyDocument(scp)).toBe(
188 normalizePolicyDocument(JSON.parse(JSON.stringify(scp))),
189 );
190 });
191 },
192);