1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import * as Test from "@/Test/Alchemy";
4import * as incidents from "@distilled.cloud/aws/ssm-incidents";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import IncidentsTestFunctionLive, { IncidentsTestFunction } from "./handler";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// Incident records are runtime entities: their ARNs embed the response-plan
17// name plus a UUID minted by StartIncident. These well-formed-but-nonexistent
18// ARNs exercise every binding operation's TYPED error union (or its
19// empty-list/idempotent success) without needing Incident Manager onboarding
20// — which CreateReplicationSet's deprecation (Nov 7, 2025) makes impossible
21// for accounts that were not already onboarded.
22const fakeArns = Effect.gen(function* () {
23 const { accountId } = yield* AWSEnvironment.current;
24 return {
25 planArn: `arn:aws:ssm-incidents::${accountId}:response-plan/alchemy-nonexistent-probe`,
26 recordArn: `arn:aws:ssm-incidents::${accountId}:incident-record/alchemy-nonexistent-probe/11111111-1111-1111-1111-111111111111`,
27 };
28});
29
30// -- Ungated typed-error probes -----------------------------------------------
31// Prove that every operation the new bindings wrap either succeeds with an
32// empty result or fails with the typed tag alchemy code relies on. These run
33// in every CI pass at near-zero cost, unlike the gated lifecycle below.
34
35test.provider(
36 "startIncident on a nonexistent response plan fails with ResourceNotFoundException",
37 () =>
38 Effect.gen(function* () {
39 const { planArn } = yield* fakeArns;
40 const error = yield* Effect.flip(
41 incidents.startIncident({ responsePlanArn: planArn }),
42 );
43 expect(error._tag).toBe("ResourceNotFoundException");
44 }),
45 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
46);
47
48test.provider(
49 "listIncidentRecords answers with a summary list",
50 () =>
51 Effect.gen(function* () {
52 const listed = yield* incidents.listIncidentRecords({});
53 expect(Array.isArray(listed.incidentRecordSummaries)).toBe(true);
54 }),
55 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
56);
57
58test.provider(
59 "getIncidentRecord on a nonexistent record fails with ResourceNotFoundException",
60 () =>
61 Effect.gen(function* () {
62 const { recordArn } = yield* fakeArns;
63 const error = yield* Effect.flip(
64 incidents.getIncidentRecord({ arn: recordArn }),
65 );
66 expect(error._tag).toBe("ResourceNotFoundException");
67 }),
68 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
69);
70
71test.provider(
72 "updateIncidentRecord on a nonexistent record fails with ResourceNotFoundException",
73 () =>
74 Effect.gen(function* () {
75 const { recordArn } = yield* fakeArns;
76 const error = yield* Effect.flip(
77 incidents.updateIncidentRecord({ arn: recordArn, title: "probe" }),
78 );
79 expect(error._tag).toBe("ResourceNotFoundException");
80 }),
81 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
82);
83
84test.provider(
85 "deleteIncidentRecord on a nonexistent record is idempotent",
86 () =>
87 Effect.gen(function* () {
88 const { recordArn } = yield* fakeArns;
89 const result = yield* Effect.result(
90 incidents.deleteIncidentRecord({ arn: recordArn }),
91 );
92 expect(Result.isSuccess(result)).toBe(true);
93 }),
94 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
95);
96
97test.provider(
98 "createTimelineEvent on a nonexistent record fails with ResourceNotFoundException",
99 () =>
100 Effect.gen(function* () {
101 const { recordArn } = yield* fakeArns;
102 const error = yield* Effect.flip(
103 incidents.createTimelineEvent({
104 incidentRecordArn: recordArn,
105 eventTime: new Date(),
106 eventType: "Custom Event",
107 eventData: JSON.stringify({ note: "probe" }),
108 }),
109 );
110 expect(error._tag).toBe("ResourceNotFoundException");
111 }),
112 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
113);
114
115test.provider(
116 "getTimelineEvent on a nonexistent record fails with ResourceNotFoundException",
117 () =>
118 Effect.gen(function* () {
119 const { recordArn } = yield* fakeArns;
120 const error = yield* Effect.flip(
121 incidents.getTimelineEvent({
122 incidentRecordArn: recordArn,
123 eventId: "11111111-1111-1111-1111-111111111111",
124 }),
125 );
126 expect(error._tag).toBe("ResourceNotFoundException");
127 }),
128 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
129);
130
131test.provider(
132 "updateTimelineEvent on a nonexistent record fails with ResourceNotFoundException",
133 () =>
134 Effect.gen(function* () {
135 const { recordArn } = yield* fakeArns;
136 const error = yield* Effect.flip(
137 incidents.updateTimelineEvent({
138 incidentRecordArn: recordArn,
139 eventId: "11111111-1111-1111-1111-111111111111",
140 eventData: JSON.stringify({ note: "probe" }),
141 }),
142 );
143 expect(error._tag).toBe("ResourceNotFoundException");
144 }),
145 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
146);
147
148test.provider(
149 "deleteTimelineEvent on a nonexistent record is idempotent",
150 () =>
151 Effect.gen(function* () {
152 const { recordArn } = yield* fakeArns;
153 const result = yield* Effect.result(
154 incidents.deleteTimelineEvent({
155 incidentRecordArn: recordArn,
156 eventId: "11111111-1111-1111-1111-111111111111",
157 }),
158 );
159 expect(Result.isSuccess(result)).toBe(true);
160 }),
161 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
162);
163
164test.provider(
165 "listTimelineEvents on a nonexistent record returns an empty list",
166 () =>
167 Effect.gen(function* () {
168 const { recordArn } = yield* fakeArns;
169 const listed = yield* incidents.listTimelineEvents({
170 incidentRecordArn: recordArn,
171 });
172 expect(listed.eventSummaries).toHaveLength(0);
173 }),
174 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
175);
176
177test.provider(
178 "listRelatedItems on a nonexistent record returns an empty list",
179 () =>
180 Effect.gen(function* () {
181 const { recordArn } = yield* fakeArns;
182 const listed = yield* incidents.listRelatedItems({
183 incidentRecordArn: recordArn,
184 });
185 expect(listed.relatedItems).toHaveLength(0);
186 }),
187 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
188);
189
190test.provider(
191 "updateRelatedItems on a nonexistent record fails with ResourceNotFoundException",
192 () =>
193 Effect.gen(function* () {
194 const { recordArn } = yield* fakeArns;
195 const error = yield* Effect.flip(
196 incidents.updateRelatedItems({
197 incidentRecordArn: recordArn,
198 relatedItemsUpdate: {
199 itemToAdd: {
200 title: "probe",
201 identifier: {
202 type: "OTHER",
203 value: { url: "https://alchemy.run" },
204 },
205 },
206 },
207 }),
208 );
209 expect(error._tag).toBe("ResourceNotFoundException");
210 }),
211 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
212);
213
214test.provider(
215 "listIncidentFindings on a nonexistent record fails with ResourceNotFoundException",
216 () =>
217 Effect.gen(function* () {
218 const { recordArn } = yield* fakeArns;
219 const error = yield* Effect.flip(
220 incidents.listIncidentFindings({ incidentRecordArn: recordArn }),
221 );
222 expect(error._tag).toBe("ResourceNotFoundException");
223 }),
224 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
225);
226
227test.provider(
228 "batchGetIncidentFindings on a nonexistent record fails with ResourceNotFoundException",
229 () =>
230 Effect.gen(function* () {
231 const { recordArn } = yield* fakeArns;
232 const error = yield* Effect.flip(
233 incidents.batchGetIncidentFindings({
234 incidentRecordArn: recordArn,
235 findingIds: ["11111111-1111-1111-1111-111111111111"],
236 }),
237 );
238 expect(error._tag).toBe("ResourceNotFoundException");
239 }),
240 { tags: ["provider:aws", "provider:aws:ssmincidents", "live"] },
241);
242
243// -- Gated end-to-end lifecycle -----------------------------------------------
244// Deploys the Lambda fixture (replication set + response plan + all 14
245// bindings) and drives the incident data plane over HTTP. Gated behind
246// AWS_TEST_INCIDENT_MANAGER=1 AND a grandfathered account: AWS deprecated
247// CreateReplicationSet on Nov 7, 2025, so accounts not already onboarded to
248// Incident Manager can no longer onboard (this test then degrades to the
249// typed deprecation probe). It also never touches a replication set it did
250// not create (capture-and-restore safety).
251
252class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
253 readonly status: number;
254 readonly body: string;
255}> {}
256
257// The Lambda fixture occasionally answers a transient 5xx (cold re-init, IAM
258// propagation on the freshly attached policy). Retry only 5xx; a genuine 4xx
259// or assertion failure surfaces immediately.
260const send = (request: HttpClientRequest.HttpClientRequest) =>
261 HttpClient.execute(request).pipe(
262 Effect.flatMap((response) =>
263 response.status >= 500
264 ? response.text.pipe(
265 Effect.flatMap((body) =>
266 Effect.fail(
267 new TransientUpstream({ status: response.status, body }),
268 ),
269 ),
270 )
271 : Effect.succeed(response),
272 ),
273 Effect.retry({
274 while: (e): boolean => e._tag === "TransientUpstream",
275 schedule: Schedule.max([
276 Schedule.exponential("500 millis"),
277 Schedule.recurs(6),
278 ]),
279 }),
280 );
281
282const json = (request: HttpClientRequest.HttpClientRequest) =>
283 send(request).pipe(Effect.flatMap((r) => r.json));
284
285test.provider.skipIf(!process.env.AWS_TEST_INCIDENT_MANAGER)(
286 "lifecycle: deployed Lambda drives the incident data plane end-to-end",
287 (stack) =>
288 Effect.gen(function* () {
289 // Never take over a replication set this test did not create.
290 const preexisting = yield* incidents.listReplicationSets({});
291 if (preexisting.replicationSetArns.length > 0) {
292 yield* Effect.logInfo(
293 `Incident Manager already onboarded (${preexisting.replicationSetArns[0]}) — skipping destructive bindings lifecycle`,
294 );
295 return;
296 }
297
298 // Probe the deprecated onboarding path; only a grandfathered account
299 // can run the lifecycle below.
300 const { region } = yield* AWSEnvironment.current;
301 const onboarded = yield* Effect.result(
302 incidents.createReplicationSet({ regions: { [region]: {} } }),
303 );
304 if (Result.isFailure(onboarded)) {
305 expect(onboarded.failure._tag).toBe("UnsupportedOperationException");
306 yield* Effect.logInfo(
307 "CreateReplicationSet is deprecated and this account is not onboarded — skipping bindings lifecycle",
308 );
309 return;
310 }
311 // The probe onboarded the account (untagged set) — remove it so the
312 // alchemy-managed deploy below starts from a clean slate.
313 const probeArn = onboarded.success.arn;
314 yield* incidents.getReplicationSet({ arn: probeArn }).pipe(
315 Effect.map((r) => r.replicationSet.status),
316 Effect.repeat({
317 schedule: Schedule.max([
318 Schedule.fixed("5 seconds"),
319 Schedule.recurs(60),
320 ]),
321 until: (status): boolean =>
322 status !== "CREATING" && status !== "UPDATING",
323 }),
324 );
325 yield* incidents.deleteReplicationSet({ arn: probeArn });
326 yield* incidents.getReplicationSet({ arn: probeArn }).pipe(
327 Effect.map((r) => r.replicationSet.status),
328 Effect.catchTag("ResourceNotFoundException", () =>
329 Effect.succeed("GONE" as const),
330 ),
331 Effect.repeat({
332 schedule: Schedule.max([
333 Schedule.fixed("5 seconds"),
334 Schedule.recurs(60),
335 ]),
336 until: (status): boolean => status === "GONE",
337 }),
338 );
339
340 yield* stack.destroy();
341
342 // Phase 1 — onboard Incident Manager (the fixture's response plan
343 // needs an ACTIVE replication set).
344 yield* stack.deploy(
345 Effect.gen(function* () {
346 return yield* AWS.SSMIncidents.ReplicationSet("Incidents", {});
347 }),
348 );
349
350 // Phase 2 — deploy the fixture Lambda + response plan + bindings.
351 const attrs = yield* stack.deploy(
352 Effect.gen(function* () {
353 return yield* IncidentsTestFunction;
354 }).pipe(Effect.provide(IncidentsTestFunctionLive)),
355 );
356 expect(attrs.functionUrl).toBeTruthy();
357 const baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
358
359 // Readiness: fresh function URLs take a while to start serving 200s.
360 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
361 Effect.flatMap((response) =>
362 response.status === 200
363 ? Effect.succeed(response)
364 : Effect.fail(new Error(`not ready: ${response.status}`)),
365 ),
366 Effect.retry({
367 schedule: Schedule.max([
368 Schedule.fixed("2 seconds"),
369 Schedule.recurs(75),
370 ]),
371 }),
372 );
373
374 const bindings = (yield* json(
375 HttpClientRequest.get(`${baseUrl}/bindings`),
376 )) as { bound: string[] };
377 expect(bindings.bound).toHaveLength(14);
378
379 // StartIncident
380 const started = (yield* json(
381 HttpClientRequest.post(`${baseUrl}/start`),
382 )) as { incidentRecordArn: string };
383 expect(started.incidentRecordArn).toContain(":incident-record/");
384 const arn = encodeURIComponent(started.incidentRecordArn);
385
386 // ListIncidentRecords — poll until the new record is listed.
387 yield* json(HttpClientRequest.get(`${baseUrl}/records`)).pipe(
388 Effect.repeat({
389 schedule: Schedule.spaced("3 seconds"),
390 until: (r): boolean =>
391 (r as { arns: string[] }).arns.includes(started.incidentRecordArn),
392 times: 20,
393 }),
394 );
395
396 // GetIncidentRecord
397 const record = (yield* json(
398 HttpClientRequest.get(`${baseUrl}/record?arn=${arn}`),
399 )) as { title: string; status: string };
400 expect(record.title).toBe("alchemy bindings test incident");
401 expect(record.status).toBe("OPEN");
402
403 // CreateTimelineEvent / GetTimelineEvent / UpdateTimelineEvent /
404 // ListTimelineEvents
405 const created = (yield* json(
406 HttpClientRequest.post(`${baseUrl}/timeline?arn=${arn}`),
407 )) as { eventId: string };
408 expect(created.eventId).toBeTruthy();
409 const eventId = encodeURIComponent(created.eventId);
410 const event = (yield* json(
411 HttpClientRequest.get(
412 `${baseUrl}/timeline?arn=${arn}&eventId=${eventId}`,
413 ),
414 )) as { eventType: string };
415 expect(event.eventType).toBe("Custom Event");
416 yield* json(
417 HttpClientRequest.post(
418 `${baseUrl}/timeline-update?arn=${arn}&eventId=${eventId}`,
419 ),
420 );
421 const events = (yield* json(
422 HttpClientRequest.get(`${baseUrl}/timeline-events?arn=${arn}`),
423 )) as { count: number };
424 expect(events.count).toBeGreaterThanOrEqual(1);
425
426 // Related items + findings
427 yield* json(HttpClientRequest.post(`${baseUrl}/related?arn=${arn}`));
428 const related = (yield* json(
429 HttpClientRequest.get(`${baseUrl}/related?arn=${arn}`),
430 )) as { count: number };
431 expect(related.count).toBeGreaterThanOrEqual(1);
432 const findings = (yield* json(
433 HttpClientRequest.get(`${baseUrl}/findings?arn=${arn}`),
434 )) as { count: number };
435 expect(findings.count).toBeGreaterThanOrEqual(0);
436
437 // Resolve, clean up the timeline event and the record.
438 yield* json(HttpClientRequest.post(`${baseUrl}/resolve?arn=${arn}`));
439 yield* json(
440 HttpClientRequest.delete(
441 `${baseUrl}/timeline?arn=${arn}&eventId=${eventId}`,
442 ),
443 );
444 yield* json(HttpClientRequest.delete(`${baseUrl}/record?arn=${arn}`));
445
446 // Destroy — response plan and Lambda first, then offboarding.
447 yield* stack.destroy();
448 const after = yield* incidents.listReplicationSets({});
449 expect(after.replicationSetArns).toHaveLength(0);
450 }),
451 // onboarding (~1-2 min) + Lambda deploy + offboarding (~1-2 min).
452 {
453 tags: [
454 "provider:aws",
455 "provider:aws:batch",
456 "provider:aws:lambda",
457 "provider:aws:ssmincidents",
458 "live",
459 ],
460 timeout: 900_000,
461 },
462);