1import * as Lambda from "@/AWS/Lambda";
2import * as SSMIncidents from "@/AWS/SSMIncidents";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export class IncidentsTestFunction extends Lambda.Function<Lambda.Function>()(
13 "IncidentsTestFunction",
14) {}
15
16/**
17 * Routes answer `{ …fields }` on success or `{ errorTag }` when the
18 * operation fails with a TYPED error — the test asserts the tag is in a
19 * route-specific allowlist, which proves both the binding wiring and the
20 * IAM grant. An untyped error crashes into a 500 instead.
21 */
22const errorTagged = <A, E extends { _tag: string }, R>(
23 effect: Effect.Effect<A, E, R>,
24): Effect.Effect<A | { errorTag: string; errorMessage?: string }, never, R> =>
25 effect.pipe(
26 Effect.map((a): A | { errorTag: string; errorMessage?: string } => a),
27 Effect.catch((e) =>
28 Effect.succeed({
29 errorTag: e._tag,
30 errorMessage: (e as { message?: string }).message,
31 }),
32 ),
33 );
34
35// One binding per Incident Manager data-plane capability. The fixture is
36// only deployable on an account onboarded to Incident Manager (the
37// replication set is the account singleton and CreateReplicationSet was
38// deprecated Nov 7, 2025), so the whole suite that deploys this is gated
39// behind AWS_TEST_INCIDENT_MANAGER=1.
40export default IncidentsTestFunction.make(
41 {
42 main,
43 functionUrl: true,
44 timeout: Duration.seconds(30),
45 },
46 Effect.gen(function* () {
47 // The account must already be onboarded (ReplicationSet deployed by the
48 // test's phase-1 deploy under the same logical id).
49 const replicationSet = yield* SSMIncidents.ReplicationSet("Incidents", {});
50
51 const plan = yield* SSMIncidents.ResponsePlan("BindingsPlan", {
52 displayName: "alchemy bindings fixture",
53 incidentTemplate: {
54 title: "alchemy bindings fixture incident",
55 impact: 5,
56 summary: "Started by the SSMIncidents Bindings test fixture",
57 },
58 // Reference the replication set so the engine orders plan creation
59 // after Incident Manager is onboarded.
60 tags: { fixture: "ssm-incidents-bindings", set: replicationSet.arn },
61 });
62
63 // Response-plan-scoped
64 const startIncident = yield* SSMIncidents.StartIncident(plan);
65
66 // Incident records
67 const listIncidentRecords = yield* SSMIncidents.ListIncidentRecords();
68 const getIncidentRecord = yield* SSMIncidents.GetIncidentRecord();
69 const updateIncidentRecord = yield* SSMIncidents.UpdateIncidentRecord();
70 const deleteIncidentRecord = yield* SSMIncidents.DeleteIncidentRecord();
71
72 // Timeline events
73 const createTimelineEvent = yield* SSMIncidents.CreateTimelineEvent();
74 const getTimelineEvent = yield* SSMIncidents.GetTimelineEvent();
75 const updateTimelineEvent = yield* SSMIncidents.UpdateTimelineEvent();
76 const deleteTimelineEvent = yield* SSMIncidents.DeleteTimelineEvent();
77 const listTimelineEvents = yield* SSMIncidents.ListTimelineEvents();
78
79 // Related items
80 const listRelatedItems = yield* SSMIncidents.ListRelatedItems();
81 const updateRelatedItems = yield* SSMIncidents.UpdateRelatedItems();
82
83 // Findings
84 const listIncidentFindings = yield* SSMIncidents.ListIncidentFindings();
85 const batchGetIncidentFindings =
86 yield* SSMIncidents.BatchGetIncidentFindings();
87
88 const bound = {
89 startIncident,
90 listIncidentRecords,
91 getIncidentRecord,
92 updateIncidentRecord,
93 deleteIncidentRecord,
94 createTimelineEvent,
95 getTimelineEvent,
96 updateTimelineEvent,
97 deleteTimelineEvent,
98 listTimelineEvents,
99 listRelatedItems,
100 updateRelatedItems,
101 listIncidentFindings,
102 batchGetIncidentFindings,
103 };
104
105 return {
106 fetch: Effect.gen(function* () {
107 const request = yield* HttpServerRequest;
108 const url = new URL(request.originalUrl);
109 const pathname = url.pathname;
110 const arn = url.searchParams.get("arn") ?? "";
111 const eventId = url.searchParams.get("eventId") ?? "";
112
113 if (request.method === "GET" && pathname === "/bindings") {
114 return yield* HttpServerResponse.json({
115 bound: Object.keys(bound),
116 });
117 }
118
119 if (request.method === "POST" && pathname === "/start") {
120 const { incidentRecordArn } = yield* startIncident({
121 title: "alchemy bindings test incident",
122 impact: 5,
123 });
124 return yield* HttpServerResponse.json({ incidentRecordArn });
125 }
126
127 if (request.method === "GET" && pathname === "/records") {
128 const { incidentRecordSummaries } = yield* listIncidentRecords({});
129 return yield* HttpServerResponse.json({
130 count: incidentRecordSummaries.length,
131 arns: incidentRecordSummaries.map((r) => r.arn),
132 });
133 }
134
135 if (request.method === "GET" && pathname === "/record") {
136 const { incidentRecord } = yield* getIncidentRecord({ arn });
137 return yield* HttpServerResponse.json({
138 title: incidentRecord.title,
139 status: incidentRecord.status,
140 impact: incidentRecord.impact,
141 });
142 }
143
144 if (request.method === "POST" && pathname === "/resolve") {
145 yield* updateIncidentRecord({ arn, status: "RESOLVED" });
146 return yield* HttpServerResponse.json({ resolved: true });
147 }
148
149 if (request.method === "DELETE" && pathname === "/record") {
150 yield* deleteIncidentRecord({ arn });
151 return yield* HttpServerResponse.json({ deleted: true });
152 }
153
154 if (request.method === "POST" && pathname === "/timeline") {
155 const created = yield* createTimelineEvent({
156 incidentRecordArn: arn,
157 eventTime: new Date(),
158 eventType: "Custom Event",
159 eventData: JSON.stringify({ note: "created by bindings test" }),
160 });
161 return yield* HttpServerResponse.json({
162 eventId: created.eventId,
163 });
164 }
165
166 if (request.method === "GET" && pathname === "/timeline") {
167 const { event } = yield* getTimelineEvent({
168 incidentRecordArn: arn,
169 eventId,
170 });
171 return yield* HttpServerResponse.json({
172 eventType: event.eventType,
173 eventData: event.eventData,
174 });
175 }
176
177 if (request.method === "POST" && pathname === "/timeline-update") {
178 yield* updateTimelineEvent({
179 incidentRecordArn: arn,
180 eventId,
181 eventData: JSON.stringify({ note: "updated by bindings test" }),
182 });
183 return yield* HttpServerResponse.json({ updated: true });
184 }
185
186 if (request.method === "DELETE" && pathname === "/timeline") {
187 yield* deleteTimelineEvent({
188 incidentRecordArn: arn,
189 eventId,
190 });
191 return yield* HttpServerResponse.json({ deleted: true });
192 }
193
194 if (request.method === "GET" && pathname === "/timeline-events") {
195 const { eventSummaries } = yield* listTimelineEvents({
196 incidentRecordArn: arn,
197 });
198 return yield* HttpServerResponse.json({
199 count: eventSummaries.length,
200 });
201 }
202
203 if (request.method === "GET" && pathname === "/related") {
204 const { relatedItems } = yield* listRelatedItems({
205 incidentRecordArn: arn,
206 });
207 return yield* HttpServerResponse.json({
208 count: relatedItems.length,
209 });
210 }
211
212 if (request.method === "POST" && pathname === "/related") {
213 const result = yield* errorTagged(
214 updateRelatedItems({
215 incidentRecordArn: arn,
216 relatedItemsUpdate: {
217 itemToAdd: {
218 title: "alchemy bindings test link",
219 identifier: {
220 type: "OTHER",
221 value: { url: "https://alchemy.run" },
222 },
223 },
224 },
225 }).pipe(Effect.map(() => ({ added: true }))),
226 );
227 return yield* HttpServerResponse.json(result);
228 }
229
230 if (request.method === "GET" && pathname === "/findings") {
231 const { findings } = yield* listIncidentFindings({
232 incidentRecordArn: arn,
233 });
234 return yield* HttpServerResponse.json({
235 count: findings.length,
236 ids: findings.map((f) => f.id),
237 });
238 }
239
240 if (request.method === "POST" && pathname === "/findings") {
241 const result = yield* errorTagged(
242 batchGetIncidentFindings({
243 incidentRecordArn: arn,
244 findingIds: [],
245 }).pipe(Effect.map((r) => ({ count: r.findings.length }))),
246 );
247 return yield* HttpServerResponse.json(result);
248 }
249
250 return yield* HttpServerResponse.json(
251 { error: "Not found", method: request.method, pathname },
252 { status: 404 },
253 );
254 }).pipe(Effect.orDie),
255 };
256 }).pipe(
257 Effect.provide(
258 Layer.mergeAll(
259 SSMIncidents.StartIncidentHttp,
260 SSMIncidents.ListIncidentRecordsHttp,
261 SSMIncidents.GetIncidentRecordHttp,
262 SSMIncidents.UpdateIncidentRecordHttp,
263 SSMIncidents.DeleteIncidentRecordHttp,
264 SSMIncidents.CreateTimelineEventHttp,
265 SSMIncidents.GetTimelineEventHttp,
266 SSMIncidents.UpdateTimelineEventHttp,
267 SSMIncidents.DeleteTimelineEventHttp,
268 SSMIncidents.ListTimelineEventsHttp,
269 SSMIncidents.ListRelatedItemsHttp,
270 SSMIncidents.UpdateRelatedItemsHttp,
271 SSMIncidents.ListIncidentFindingsHttp,
272 SSMIncidents.BatchGetIncidentFindingsHttp,
273 ),
274 ),
275 ),
276);