| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as SSMIncidents from "@/AWS/SSMIncidents"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 7 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 8 | import path from "pathe"; |
| 9 | |
| 10 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 11 | |
| 12 | export 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 | */ |
| 22 | const 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. |
| 40 | export 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 | ); |