1import * as AWS from "@/AWS";
2import { ServiceLinkedRole } from "@/AWS/IAM";
3import * as Test from "@/Test/Alchemy";
4import * as IAM from "@distilled.cloud/aws/iam";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// The deletion task can report SUCCEEDED slightly before `getRole` stops
12// returning the role — poll (bounded) until IAM catches up.
13const waitUntilRoleGone = (roleName: string) =>
14 IAM.getRole({ RoleName: roleName }).pipe(
15 Effect.option,
16 Effect.repeat({
17 schedule: Schedule.spaced("2 seconds"),
18 until: (result) => result._tag === "None",
19 times: 15,
20 }),
21 );
22
23// Auto Scaling supports custom suffixes and deletes cleanly when no Auto
24// Scaling groups reference the role — the canonical safely-testable SLR.
25// Suffixes keep these tests away from the account's shared (unsuffixed)
26// AWSServiceRoleForAutoScaling, which other infrastructure may depend on.
27const testServiceName = "autoscaling.amazonaws.com";
28const testSuffix = "AlchemyTest";
29const testAdoptSuffix = "AlchemyAdopt";
30// Deterministic physical name of the out-of-band SLR the adoption test
31// creates: Auto Scaling names suffixed SLRs `AWSServiceRoleForAutoScaling_
32// {suffix}`.
33const adoptRoleName = `AWSServiceRoleForAutoScaling_${testAdoptSuffix}`;
34
35// Idempotent out-of-band cleanup for the adoption SLR — safe when the stack
36// already deleted it (or it never fully created). Submits the async deletion
37// task and tolerates not-found.
38const deleteAdoptRoleIfExists = IAM.deleteServiceLinkedRole({
39 RoleName: adoptRoleName,
40}).pipe(
41 Effect.catchTag("NoSuchEntityException", () =>
42 Effect.succeed({ DeletionTaskId: "" }),
43 ),
44 Effect.asVoid,
45 // Any non-not-found failure is a real cleanup bug — surface it loudly.
46 Effect.orDie,
47);
48
49describe(
50 "AWS.IAM.ServiceLinkedRole",
51 { tags: ["provider:aws", "provider:aws:iam", "live"] },
52 () => {
53 test.provider(
54 "create, update description, and delete a service-linked role",
55 (stack) =>
56 Effect.gen(function* () {
57 yield* stack.destroy();
58
59 const deployRole = (description: string) =>
60 stack.deploy(
61 Effect.gen(function* () {
62 return yield* ServiceLinkedRole("AutoScalingSLR", {
63 awsServiceName: testServiceName,
64 customSuffix: testSuffix,
65 description,
66 });
67 }),
68 );
69
70 const created = yield* deployRole("alchemy service-linked role test");
71
72 expect(created.roleName.endsWith(`_${testSuffix}`)).toBe(true);
73 expect(created.awsServiceName).toBe(testServiceName);
74 expect(created.customSuffix).toBe(testSuffix);
75
76 // Out-of-band: the role exists under the service's deterministic path.
77 const observed = yield* IAM.getRole({ RoleName: created.roleName });
78 expect(observed.Role?.Path).toBe(
79 `/aws-service-role/${testServiceName}/`,
80 );
81 expect(observed.Role?.Arn).toBe(created.roleArn);
82 expect(observed.Role?.Description).toBe(
83 "alchemy service-linked role test",
84 );
85
86 // Update — the description is the only mutable aspect of an SLR.
87 const updated = yield* deployRole("alchemy SLR updated description");
88 expect(updated.roleArn).toBe(created.roleArn);
89
90 const afterUpdate = yield* IAM.getRole({
91 RoleName: created.roleName,
92 });
93 expect(afterUpdate.Role?.Description).toBe(
94 "alchemy SLR updated description",
95 );
96
97 // Destroy — the provider waits for the async deletion task; give
98 // getRole a bounded window to observe the removal.
99 yield* stack.destroy();
100
101 const deleted = yield* waitUntilRoleGone(created.roleName);
102 expect(deleted._tag).toBe("None");
103 }),
104 { timeout: 120_000 },
105 );
106
107 test.provider(
108 "adopts a service-linked role that already exists",
109 (stack) =>
110 Effect.gen(function* () {
111 yield* stack.destroy();
112
113 // Pre-create the role out-of-band, simulating a service-created (or
114 // operator-created) SLR that predates the stack.
115 const preexisting = yield* IAM.createServiceLinkedRole({
116 AWSServiceName: testServiceName,
117 CustomSuffix: testAdoptSuffix,
118 }).pipe(
119 // Idempotence across re-runs: an earlier interrupted run may have
120 // left the role behind — the create then reports InvalidInput.
121 Effect.catchTag("InvalidInputException", () =>
122 Effect.succeed({ Role: undefined }),
123 ),
124 );
125
126 const adopted = yield* stack.deploy(
127 Effect.gen(function* () {
128 return yield* ServiceLinkedRole("AdoptedSLR", {
129 awsServiceName: testServiceName,
130 customSuffix: testAdoptSuffix,
131 });
132 }),
133 );
134
135 expect(adopted.roleName.endsWith(`_${testAdoptSuffix}`)).toBe(true);
136 if (preexisting.Role) {
137 expect(adopted.roleArn).toBe(preexisting.Role.Arn);
138 }
139
140 // Destroy deletes the adopted role (deletion awaited by the provider).
141 yield* stack.destroy();
142
143 const deleted = yield* waitUntilRoleGone(adopted.roleName);
144 expect(deleted._tag).toBe("None");
145 }).pipe(
146 // The role is created out-of-band, so the scratch stack only cleans
147 // it once the deploy adopts it. Guarantee deletion on failure or
148 // interruption in the window between create and adoption.
149 Effect.ensuring(deleteAdoptRoleIfExists),
150 ),
151 { timeout: 120_000 },
152 );
153 },
154);