1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import * as Test from "@/Test/Alchemy";
4import * as contacts from "@distilled.cloud/aws/ssm-contacts";
5import * as incidents from "@distilled.cloud/aws/ssm-incidents";
6import { expect } from "alchemy-test";
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";
11
12import ContactsBindingsFunctionLive, {
13 ContactsBindingsFunction,
14} from "./bindings-handler.ts";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// Ungated typed-error probes: prove the distilled error union carries the
19// tags the runtime bindings depend on. On an account that has not onboarded
20// Incident Manager every ssm-contacts call fails with the synthetic
21// `IncidentManagerNotOnboarded` tag (patched from the overloaded
22// ValidationException "Account not found for the request"); once onboarded,
23// a missing engagement/rotation/channel is a plain
24// `ResourceNotFoundException`.
25test.provider(
26 "describeEngagement on a nonexistent engagement fails with a typed tag",
27 () =>
28 Effect.gen(function* () {
29 const { accountId, region } = yield* AWSEnvironment.current;
30 const error = yield* Effect.flip(
31 contacts.describeEngagement({
32 EngagementId: `arn:aws:ssm-contacts:${region}:${accountId}:engagement/alchemy-nonexistent-probe/00000000-0000-0000-0000-000000000000`,
33 }),
34 );
35 expect([
36 "ResourceNotFoundException",
37 "IncidentManagerNotOnboarded",
38 ]).toContain(error._tag);
39 }),
40 { tags: ["provider:aws", "provider:aws:ssmcontacts", "live"] },
41);
42
43test.provider(
44 "listRotationShifts on a nonexistent rotation fails with a typed tag",
45 () =>
46 Effect.gen(function* () {
47 const { accountId, region } = yield* AWSEnvironment.current;
48 const error = yield* Effect.flip(
49 contacts.listRotationShifts({
50 RotationId: `arn:aws:ssm-contacts:${region}:${accountId}:rotation/alchemy-nonexistent-probe`,
51 EndTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
52 }),
53 );
54 // AWS reports an unresolvable rotation ARN as a ValidationException
55 // ("Invalid value provided - Invalid resource Arn ...") rather than a
56 // ResourceNotFoundException — typed as InvalidRotationArn.
57 expect([
58 "ResourceNotFoundException",
59 "IncidentManagerNotOnboarded",
60 "InvalidRotationArn",
61 ]).toContain(error._tag);
62 }),
63 { tags: ["provider:aws", "provider:aws:ssmcontacts", "live"] },
64);
65
66test.provider(
67 "sendActivationCode on a nonexistent channel fails with a typed tag",
68 () =>
69 Effect.gen(function* () {
70 const { accountId, region } = yield* AWSEnvironment.current;
71 const error = yield* Effect.flip(
72 contacts.sendActivationCode({
73 ContactChannelId: `arn:aws:ssm-contacts:${region}:${accountId}:contact-channel/alchemy-nonexistent-probe/11111111-1111-1111-1111-111111111111`,
74 }),
75 );
76 expect([
77 "ResourceNotFoundException",
78 "IncidentManagerNotOnboarded",
79 ]).toContain(error._tag);
80 }),
81 { tags: ["provider:aws", "provider:aws:ssmcontacts", "live"] },
82);
83
84// The bindings fixture needs the account-wide Incident Manager replication
85// set. AWS deprecated CreateReplicationSet on Nov 7, 2025, so only accounts
86// that were already onboarded can run the fixture — return the replication
87// set ARN, or `undefined` when the account cannot be onboarded.
88const ensureReplicationSet = Effect.gen(function* () {
89 const listed = yield* incidents.listReplicationSets({});
90 let arn = listed.replicationSetArns[0];
91 if (arn === undefined) {
92 const { region } = yield* AWSEnvironment.current;
93 const created = yield* Effect.result(
94 incidents.createReplicationSet({ regions: { [region]: {} } }),
95 );
96 if (Result.isFailure(created)) {
97 expect(created.failure._tag).toBe("UnsupportedOperationException");
98 return undefined;
99 }
100 arn = created.success.arn;
101 }
102 const status = yield* incidents.getReplicationSet({ arn }).pipe(
103 Effect.map((r) => r.replicationSet.status),
104 Effect.repeat({
105 schedule: Schedule.max([
106 Schedule.fixed("5 seconds"),
107 Schedule.recurs(60),
108 ]),
109 until: (status): boolean =>
110 status !== "CREATING" && status !== "UPDATING",
111 }),
112 );
113 expect(status).toBe("ACTIVE");
114 return arn;
115});
116
117// The runtime-binding fixture needs real contacts, which need the
118// account-wide Incident Manager replication set — gated like the resource
119// lifecycle test (AWS deprecated CreateReplicationSet on Nov 7, 2025, so
120// only already-onboarded accounts can run this).
121test.provider.skipIf(!process.env.AWS_TEST_INCIDENT_MANAGER)(
122 "bindings fixture: engagements, pages, rotation shifts, and overrides",
123 (stack) =>
124 Effect.gen(function* () {
125 const replicationSetArn = yield* ensureReplicationSet;
126 if (replicationSetArn === undefined) {
127 yield* Effect.logInfo(
128 "CreateReplicationSet is deprecated (Nov 7, 2025) and this account is not onboarded to Incident Manager — skipping the SSM Contacts bindings fixture",
129 );
130 return;
131 }
132 yield* stack.destroy();
133
134 const attrs = yield* stack.deploy(
135 Effect.gen(function* () {
136 return yield* ContactsBindingsFunction;
137 }).pipe(Effect.provide(ContactsBindingsFunctionLive)),
138 );
139 expect(attrs.functionUrl).toBeTruthy();
140 const baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
141
142 const client = yield* HttpClient.HttpClient;
143 const getJson = (path: string) =>
144 client
145 .get(`${baseUrl}${path}`)
146 .pipe(
147 Effect.flatMap((response) =>
148 response.status === 200
149 ? response.json
150 : Effect.flatMap(response.text, (body) =>
151 Effect.fail(
152 new Error(`GET ${path} -> ${response.status}: ${body}`),
153 ),
154 ),
155 ),
156 );
157
158 // Lambda URL cold-start + IAM propagation on the fresh policy.
159 const bindings = (yield* getJson("/bindings").pipe(
160 Effect.retry({
161 schedule: Schedule.max([
162 Schedule.exponential("2 seconds"),
163 Schedule.recurs(30),
164 ]),
165 }),
166 )) as { bound: string[] };
167 expect(bindings.bound).toHaveLength(19);
168
169 const shifts = (yield* getJson("/shifts")) as {
170 shifts: number;
171 previewShifts: number;
172 };
173 expect(shifts.previewShifts).toBeGreaterThanOrEqual(0);
174
175 const override = (yield* getJson("/override")) as {
176 overrideId: string;
177 newContactIds: string[];
178 listed: number;
179 };
180 expect(override.overrideId).toBeTruthy();
181 expect(override.listed).toBeGreaterThanOrEqual(1);
182
183 const engage = (yield* getJson("/engage")) as {
184 engagementArn: string;
185 subject: string;
186 pages: number;
187 };
188 expect(engage.engagementArn).toContain(":engagement/");
189 expect(engage.subject).toBe("alchemy bindings fixture engagement");
190
191 const pages = (yield* getJson("/pages")) as {
192 engagements: number;
193 pages: number;
194 };
195 expect(pages.engagements).toBeGreaterThanOrEqual(1);
196
197 const channel = (yield* getJson("/channel")) as { ok: boolean };
198 expect(channel.ok).toBe(true);
199
200 yield* stack.destroy();
201 }),
202 {
203 tags: [
204 "provider:aws",
205 "provider:aws:lambda",
206 "provider:aws:ssmcontacts",
207 "provider:aws:ssmincidents",
208 "live",
209 ],
210 timeout: 600_000,
211 },
212);