1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import { Contact } from "@/AWS/SSMContacts/Contact.ts";
4import { ContactChannel } from "@/AWS/SSMContacts/ContactChannel.ts";
5import { Plan } from "@/AWS/SSMContacts/Plan.ts";
6import { Rotation } from "@/AWS/SSMContacts/Rotation.ts";
7import * as Test from "@/Test/Alchemy";
8import * as contacts from "@distilled.cloud/aws/ssm-contacts";
9import * as incidents from "@distilled.cloud/aws/ssm-incidents";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Result from "effect/Result";
13import * as Schedule from "effect/Schedule";
14
15const { test } = Test.make({ providers: AWS.providers() });
16
17// Ungated typed-error probe: prove the distilled error union carries the
18// tags this provider's read/delete paths depend on. On an account that has
19// not onboarded Incident Manager, every ssm-contacts call fails with the
20// synthetic `IncidentManagerNotOnboarded` tag (patched from the overloaded
21// ValidationException "Account not found for the request"); once onboarded,
22// a missing contact is a plain `ResourceNotFoundException`.
23test.provider(
24 "getContact on a nonexistent contact fails with a typed tag",
25 () =>
26 Effect.gen(function* () {
27 const { accountId, region } = yield* AWSEnvironment.current;
28 const error = yield* Effect.flip(
29 contacts.getContact({
30 ContactId: `arn:aws:ssm-contacts:${region}:${accountId}:contact/alchemy-nonexistent-probe`,
31 }),
32 );
33 expect([
34 "ResourceNotFoundException",
35 "IncidentManagerNotOnboarded",
36 ]).toContain(error._tag);
37 }),
38 { tags: ["provider:aws", "provider:aws:ssmcontacts", "live"] },
39);
40
41// Contacts require the Incident Manager replication set (the account
42// singleton that AWS.SSMIncidents.ReplicationSet manages). Ensure it exists
43// and is ACTIVE; if we create it here we intentionally LEAVE it in place —
44// offboarding Incident Manager account-wide is only exercised by the gated
45// ReplicationSet lifecycle test. Returns the replication set ARN, or
46// `undefined` when the account cannot be onboarded: AWS deprecated
47// CreateReplicationSet on Nov 7, 2025, so only accounts that were already
48// onboarded can exercise the contacts lifecycle.
49const ensureReplicationSet = Effect.gen(function* () {
50 const listed = yield* incidents.listReplicationSets({});
51 let arn = listed.replicationSetArns[0];
52 if (arn === undefined) {
53 const { region } = yield* AWSEnvironment.current;
54 const created = yield* Effect.result(
55 incidents.createReplicationSet({ regions: { [region]: {} } }),
56 );
57 if (Result.isFailure(created)) {
58 expect(created.failure._tag).toBe("UnsupportedOperationException");
59 return undefined;
60 }
61 arn = created.success.arn;
62 }
63 const status = yield* incidents.getReplicationSet({ arn }).pipe(
64 Effect.map((r) => r.replicationSet.status),
65 Effect.repeat({
66 schedule: Schedule.max([
67 Schedule.fixed("5 seconds"),
68 Schedule.recurs(60),
69 ]),
70 until: (status) => status !== "CREATING" && status !== "UPDATING",
71 }),
72 );
73 expect(status).toBe("ACTIVE");
74 return arn;
75});
76
77// Contact / ContactChannel / Plan / Rotation are cheap once the replication
78// set exists, but they depend on that account-wide singleton — gate the whole
79// lifecycle behind AWS_TEST_INCIDENT_MANAGER=1.
80test.provider.skipIf(!process.env.AWS_TEST_INCIDENT_MANAGER)(
81 "lifecycle: contact + channel + engagement plan + rotation",
82 (stack) =>
83 Effect.gen(function* () {
84 const replicationSetArn = yield* ensureReplicationSet;
85 if (replicationSetArn === undefined) {
86 yield* Effect.logInfo(
87 "CreateReplicationSet is deprecated (Nov 7, 2025) and this account is not onboarded to Incident Manager — skipping contacts lifecycle",
88 );
89 return;
90 }
91 yield* stack.destroy();
92
93 // Create — a personal contact with a deferred-activation email channel,
94 // a staged engagement plan targeting that channel, and a daily rotation.
95 const deployed = yield* stack.deploy(
96 Effect.gen(function* () {
97 const oncall = yield* Contact("Oncall", {
98 type: "PERSONAL",
99 displayName: "Primary On-Call",
100 tags: { fixture: "ssm-contacts" },
101 });
102 const email = yield* ContactChannel("Email", {
103 contactId: oncall.contactArn,
104 type: "EMAIL",
105 deliveryAddress: { SimpleAddress: "oncall@example.com" },
106 deferActivation: true,
107 });
108 const plan = yield* Plan("OncallPlan", {
109 contactId: oncall.contactArn,
110 stages: [
111 {
112 DurationInMinutes: 5,
113 Targets: [
114 {
115 ChannelTargetInfo: {
116 ContactChannelId: email.contactChannelArn,
117 RetryIntervalInMinutes: 1,
118 },
119 },
120 ],
121 },
122 ],
123 });
124 const rotation = yield* Rotation("Primary", {
125 contactIds: [oncall.contactArn],
126 timeZoneId: "America/Los_Angeles",
127 startTime: "2030-01-01T00:00:00Z",
128 recurrence: {
129 NumberOfOnCalls: 1,
130 RecurrenceMultiplier: 1,
131 DailySettings: [{ HourOfDay: 9, MinuteOfHour: 0 }],
132 },
133 tags: { fixture: "ssm-contacts" },
134 });
135 return { oncall, email, plan, rotation };
136 }),
137 );
138
139 expect(deployed.oncall.contactArn).toContain(":contact/");
140 expect(deployed.oncall.type).toBe("PERSONAL");
141 expect(deployed.email.contactChannelArn).toContain(":contact-channel/");
142 expect(deployed.email.activationStatus).toBe("NOT_ACTIVATED");
143 expect(deployed.plan.stageCount).toBe(1);
144 expect(deployed.rotation.rotationArn).toContain(":rotation/");
145
146 // Out-of-band verification via distilled.
147 const liveContact = yield* contacts.getContact({
148 ContactId: deployed.oncall.contactArn,
149 });
150 expect(liveContact.Alias).toBe(deployed.oncall.alias);
151 expect(liveContact.DisplayName).toBe("Primary On-Call");
152 expect(liveContact.Plan.Stages).toHaveLength(1);
153 const contactTags = yield* contacts.listTagsForResource({
154 ResourceARN: deployed.oncall.contactArn,
155 });
156 expect(
157 contactTags.Tags?.some(
158 (t) => t.Key === "alchemy::id" && t.Value === "Oncall",
159 ),
160 ).toBe(true);
161
162 const liveChannel = yield* contacts.getContactChannel({
163 ContactChannelId: deployed.email.contactChannelArn,
164 });
165 expect(liveChannel.Type).toBe("EMAIL");
166 expect(liveChannel.DeliveryAddress.SimpleAddress).toBe(
167 "oncall@example.com",
168 );
169
170 const liveRotation = yield* contacts.getRotation({
171 RotationId: deployed.rotation.rotationArn,
172 });
173 expect(liveRotation.TimeZoneId).toBe("America/Los_Angeles");
174 expect(liveRotation.Recurrence.DailySettings?.[0]?.HourOfDay).toBe(9);
175
176 // Update — display name, channel address, plan stage duration, and
177 // rotation hand-off time all update in place; a resource policy is
178 // attached (put/getContactPolicy sync).
179 const { accountId } = yield* AWSEnvironment.current;
180 const sharePolicy = {
181 Version: "2012-10-17",
182 Statement: [
183 {
184 Effect: "Allow",
185 Principal: { AWS: accountId },
186 Action: ["ssm-contacts:GetContact"],
187 Resource: [
188 deployed.oncall.contactArn,
189 `${deployed.oncall.contactArn.replace(":contact/", ":engagement/")}/*`,
190 ],
191 },
192 ],
193 };
194 yield* stack.deploy(
195 Effect.gen(function* () {
196 const oncall = yield* Contact("Oncall", {
197 type: "PERSONAL",
198 displayName: "Secondary On-Call",
199 policy: sharePolicy,
200 tags: { fixture: "ssm-contacts", env: "test" },
201 });
202 const email = yield* ContactChannel("Email", {
203 contactId: oncall.contactArn,
204 type: "EMAIL",
205 deliveryAddress: { SimpleAddress: "standby@example.com" },
206 deferActivation: true,
207 });
208 const plan = yield* Plan("OncallPlan", {
209 contactId: oncall.contactArn,
210 stages: [
211 {
212 DurationInMinutes: 10,
213 Targets: [
214 {
215 ChannelTargetInfo: {
216 ContactChannelId: email.contactChannelArn,
217 RetryIntervalInMinutes: 2,
218 },
219 },
220 ],
221 },
222 ],
223 });
224 const rotation = yield* Rotation("Primary", {
225 contactIds: [oncall.contactArn],
226 timeZoneId: "America/Los_Angeles",
227 startTime: "2030-01-01T00:00:00Z",
228 recurrence: {
229 NumberOfOnCalls: 1,
230 RecurrenceMultiplier: 1,
231 DailySettings: [{ HourOfDay: 10, MinuteOfHour: 30 }],
232 },
233 tags: { fixture: "ssm-contacts" },
234 });
235 return { oncall, email, plan, rotation };
236 }),
237 );
238
239 const updatedContact = yield* contacts.getContact({
240 ContactId: deployed.oncall.contactArn,
241 });
242 expect(updatedContact.DisplayName).toBe("Secondary On-Call");
243 expect(updatedContact.Plan.Stages?.[0]?.DurationInMinutes).toBe(10);
244 const updatedChannel = yield* contacts.getContactChannel({
245 ContactChannelId: deployed.email.contactChannelArn,
246 });
247 expect(updatedChannel.DeliveryAddress.SimpleAddress).toBe(
248 "standby@example.com",
249 );
250 const updatedRotation = yield* contacts.getRotation({
251 RotationId: deployed.rotation.rotationArn,
252 });
253 expect(updatedRotation.Recurrence.DailySettings?.[0]?.HourOfDay).toBe(10);
254 const updatedTags = yield* contacts.listTagsForResource({
255 ResourceARN: deployed.oncall.contactArn,
256 });
257 expect(
258 updatedTags.Tags?.some((t) => t.Key === "env" && t.Value === "test"),
259 ).toBe(true);
260 const updatedPolicy = yield* contacts.getContactPolicy({
261 ContactArn: deployed.oncall.contactArn,
262 });
263 expect(updatedPolicy.Policy).toContain("ssm-contacts:GetContact");
264
265 // Destroy — everything but the account-singleton replication set.
266 yield* stack.destroy();
267 const contactError = yield* Effect.flip(
268 contacts.getContact({ ContactId: deployed.oncall.contactArn }),
269 );
270 expect(contactError._tag).toBe("ResourceNotFoundException");
271 const rotationError = yield* Effect.flip(
272 contacts.getRotation({ RotationId: deployed.rotation.rotationArn }),
273 );
274 expect(rotationError._tag).toBe("ResourceNotFoundException");
275 }),
276 // ensureReplicationSet may onboard Incident Manager (~1-2 min) on first run.
277 {
278 tags: [
279 "provider:aws",
280 "provider:aws:ssmcontacts",
281 "provider:aws:ssmincidents",
282 "live",
283 ],
284 timeout: 600_000,
285 },
286);