1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import { ReplicationSet } from "@/AWS/SSMIncidents/ReplicationSet.ts";
4import { ResponsePlan } from "@/AWS/SSMIncidents/ResponsePlan.ts";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as incidents from "@distilled.cloud/aws/ssm-incidents";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Result from "effect/Result";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Ungated typed-error probes: prove the distilled error union carries the
16// tags this provider's read/delete paths depend on. Run in every CI pass at
17// near-zero cost, unlike the gated lifecycle below.
18test.provider(
19 "listReplicationSets succeeds whether or not Incident Manager is onboarded",
20 () =>
21 Effect.gen(function* () {
22 const listed = yield* incidents.listReplicationSets({});
23 expect(Array.isArray(listed.replicationSetArns)).toBe(true);
24 }),
25 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
26);
27
28test.provider(
29 "getReplicationSet on a nonexistent arn fails with ResourceNotFoundException",
30 () =>
31 Effect.gen(function* () {
32 const { accountId } = yield* AWSEnvironment.current;
33 const error = yield* Effect.flip(
34 incidents.getReplicationSet({
35 arn: `arn:aws:ssm-incidents::${accountId}:replication-set/00000000-0000-4000-8000-000000000000`,
36 }),
37 );
38 expect(error._tag).toBe("ResourceNotFoundException");
39 }),
40 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
41);
42
43test.provider(
44 "getResponsePlan on a nonexistent arn fails with ResourceNotFoundException",
45 () =>
46 Effect.gen(function* () {
47 const { accountId } = yield* AWSEnvironment.current;
48 // Response plan ARNs are region-less (they live in the replication set).
49 const error = yield* Effect.flip(
50 incidents.getResponsePlan({
51 arn: `arn:aws:ssm-incidents::${accountId}:response-plan/alchemy-nonexistent-probe`,
52 }),
53 );
54 expect(error._tag).toBe("ResourceNotFoundException");
55 }),
56 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
57);
58
59// The replication set is the account/region singleton that ONBOARDS Incident
60// Manager (create) and OFFBOARDS it account-wide (delete, ~1-2 minutes). The
61// destructive lifecycle is gated behind AWS_TEST_INCIDENT_MANAGER=1 and only
62// runs when the account is not already onboarded — it must never delete a
63// replication set the user already operates (capture-and-restore safety).
64test.provider.skipIf(!process.env.AWS_TEST_INCIDENT_MANAGER)(
65 "lifecycle: onboard Incident Manager, deploy response plan, offboard",
66 (stack) =>
67 Effect.gen(function* () {
68 const preexisting = yield* incidents.listReplicationSets({});
69 if (preexisting.replicationSetArns.length > 0) {
70 yield* Effect.logInfo(
71 `Incident Manager already onboarded (${preexisting.replicationSetArns[0]}) — skipping destructive lifecycle test`,
72 );
73 return;
74 }
75
76 // AWS deprecated CreateReplicationSet on Nov 7, 2025 — accounts not
77 // already onboarded to Incident Manager can no longer onboard. Probe
78 // once and assert the typed deprecation tag; only a grandfathered
79 // (pre-deprecation) account can exercise the full lifecycle below.
80 const { region } = yield* AWSEnvironment.current;
81 const onboarded = yield* Effect.result(
82 incidents.createReplicationSet({ regions: { [region]: {} } }),
83 );
84 if (Result.isFailure(onboarded)) {
85 expect(onboarded.failure._tag).toBe("UnsupportedOperationException");
86 yield* Effect.logInfo(
87 "CreateReplicationSet is deprecated (Nov 7, 2025) and this account is not onboarded to Incident Manager — skipping lifecycle",
88 );
89 return;
90 }
91 // The probe unexpectedly onboarded the account (untagged set) — remove
92 // it so the alchemy-managed lifecycle below starts from a clean slate.
93 yield* incidents.getReplicationSet({ arn: onboarded.success.arn }).pipe(
94 Effect.map((r) => r.replicationSet.status),
95 Effect.repeat({
96 schedule: Schedule.max([
97 Schedule.fixed("5 seconds"),
98 Schedule.recurs(60),
99 ]),
100 until: (status) => status !== "CREATING" && status !== "UPDATING",
101 }),
102 );
103 yield* incidents.deleteReplicationSet({ arn: onboarded.success.arn });
104 yield* incidents.getReplicationSet({ arn: onboarded.success.arn }).pipe(
105 Effect.map((r) => r.replicationSet.status),
106 Effect.catchTag("ResourceNotFoundException", () =>
107 Effect.succeed("GONE" as const),
108 ),
109 Effect.repeat({
110 schedule: Schedule.max([
111 Schedule.fixed("5 seconds"),
112 Schedule.recurs(60),
113 ]),
114 until: (status) => status === "GONE",
115 }),
116 );
117
118 yield* stack.destroy();
119
120 // Create — onboard Incident Manager in the ambient region.
121 const created = yield* stack.deploy(
122 Effect.gen(function* () {
123 return yield* ReplicationSet("Incidents", {
124 tags: { env: "test" },
125 });
126 }),
127 );
128 expect(created.arn).toContain(":replication-set/");
129 expect(created.status).toBe("ACTIVE");
130 expect(created.regionNames).toContain(region);
131 expect(created.deletionProtected).toBe(false);
132
133 // Out-of-band verification via distilled.
134 const live = yield* incidents.getReplicationSet({ arn: created.arn });
135 expect(live.replicationSet.status).toBe("ACTIVE");
136 const tags = yield* incidents.listTagsForResource({
137 resourceArn: created.arn,
138 });
139 expect(tags.tags["alchemy::id"]).toBe("Incidents");
140 expect(tags.tags["env"]).toBe("test");
141
142 // Canonical list() coverage.
143 const provider = yield* Provider.findProvider(ReplicationSet);
144 const all = yield* provider.list();
145 expect(all.some((rs) => rs.arn === created.arn)).toBe(true);
146
147 // Update — enable deletion protection + retag, and add a response
148 // plan now that the account is onboarded.
149 const planName = "alchemy-test-response-plan";
150 const { plan } = yield* stack.deploy(
151 Effect.gen(function* () {
152 const replicationSet = yield* ReplicationSet("Incidents", {
153 deletionProtected: true,
154 tags: { env: "prod" },
155 });
156 const plan = yield* ResponsePlan("Critical", {
157 name: planName,
158 displayName: "Critical incidents",
159 incidentTemplate: {
160 title: "Critical failure",
161 impact: 3,
162 summary: "Automated test response plan",
163 },
164 tags: { fixture: "ssm-incidents" },
165 });
166 return { replicationSet, plan };
167 }),
168 );
169 expect(plan.arn).toContain(":response-plan/");
170 expect(plan.name).toBe(planName);
171
172 const afterUpdate = yield* incidents.getReplicationSet({
173 arn: created.arn,
174 });
175 expect(afterUpdate.replicationSet.deletionProtected).toBe(true);
176 const updatedTags = yield* incidents.listTagsForResource({
177 resourceArn: created.arn,
178 });
179 expect(updatedTags.tags["env"]).toBe("prod");
180
181 const livePlan = yield* incidents.getResponsePlan({ arn: plan.arn });
182 expect(livePlan.name).toBe(planName);
183 expect(livePlan.displayName).toBe("Critical incidents");
184 expect(livePlan.incidentTemplate.title).toBe("Critical failure");
185 expect(livePlan.incidentTemplate.impact).toBe(3);
186
187 // Update the response plan in place.
188 yield* stack.deploy(
189 Effect.gen(function* () {
190 const replicationSet = yield* ReplicationSet("Incidents", {
191 deletionProtected: true,
192 tags: { env: "prod" },
193 });
194 const plan = yield* ResponsePlan("Critical", {
195 name: planName,
196 displayName: "Critical incidents (updated)",
197 incidentTemplate: {
198 title: "Critical failure",
199 impact: 2,
200 summary: "Automated test response plan (updated)",
201 },
202 tags: { fixture: "ssm-incidents" },
203 });
204 return { replicationSet, plan };
205 }),
206 );
207 const updatedPlan = yield* incidents.getResponsePlan({ arn: plan.arn });
208 expect(updatedPlan.displayName).toBe("Critical incidents (updated)");
209 expect(updatedPlan.incidentTemplate.impact).toBe(2);
210
211 // Destroy — the response plan is deleted first, deletion protection is
212 // lifted automatically, and Incident Manager is offboarded. The
213 // provider's delete waits until the set is fully gone.
214 yield* stack.destroy();
215 const planError = yield* Effect.flip(
216 incidents.getResponsePlan({ arn: plan.arn }),
217 );
218 expect(planError._tag).toBe("ResourceNotFoundException");
219 const after = yield* incidents.listReplicationSets({});
220 expect(after.replicationSetArns).toHaveLength(0);
221 }),
222 // onboarding (~1-2 min) + updates + offboarding (~1-2 min).
223 {
224 tags: ["provider:aws", "provider:aws:ssmincidents", "live"],
225 timeout: 900_000,
226 },
227);