1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import ApplicationSignalsTestFunctionLive, {
11 ApplicationSignalsTestFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(
17 testOptions,
18 "ApplicationSignalsBindings",
19);
20
21// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
22// over 60s on a fresh deploy under parallel-suite load.
23const readinessPolicy = Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(75),
26]);
27
28let baseUrl: string;
29
30class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
31 readonly status: number;
32 readonly body: string;
33}> {}
34
35// The shared Lambda fixture occasionally answers a transient 5xx under
36// full-suite parallel load (cold re-init, IAM propagation on the freshly
37// attached policy that the handler's `Effect.orDie` surfaces as a 500).
38// Retry only 5xx; a genuine 4xx/assertion failure surfaces immediately.
39const send = (request: HttpClientRequest.HttpClientRequest) =>
40 HttpClient.execute(request).pipe(
41 Effect.flatMap((response) =>
42 response.status >= 500
43 ? response.text.pipe(
44 Effect.flatMap((body) =>
45 Effect.fail(
46 new TransientUpstream({ status: response.status, body }),
47 ),
48 ),
49 )
50 : Effect.succeed(response),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "TransientUpstream",
54 // Fresh-role IAM propagation can take ~60s right after deploy; the
55 // handler's Effect.orDie surfaces a still-propagating grant as a 500.
56 schedule: Schedule.max([
57 Schedule.fixed("5 seconds"),
58 Schedule.recurs(12),
59 ]),
60 }),
61 );
62
63const getJson = (path: string) =>
64 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
65 Effect.flatMap((r) => r.json),
66 );
67
68describe.sequential(
69 "ApplicationSignals Bindings",
70 {
71 tags: [
72 "provider:aws",
73 "provider:aws:applicationsignals",
74 "provider:aws:batch",
75 "provider:aws:lambda",
76 "live",
77 ],
78 },
79 () => {
80 beforeAll(
81 Effect.gen(function* () {
82 yield* Effect.logInfo(
83 "ApplicationSignals test setup: destroying previous resources",
84 );
85 yield* sharedStack.destroy();
86
87 yield* Effect.logInfo(
88 "ApplicationSignals test setup: deploying fixture",
89 );
90 const { functionUrl } = yield* sharedStack.deploy(
91 Effect.gen(function* () {
92 return yield* ApplicationSignalsTestFunction;
93 }).pipe(Effect.provide(ApplicationSignalsTestFunctionLive)),
94 );
95
96 expect(functionUrl).toBeTruthy();
97 baseUrl = functionUrl!.replace(/\/+$/, "");
98
99 const readinessUrl = `${baseUrl}/bindings`;
100 yield* Effect.logInfo(
101 `ApplicationSignals test setup: probing readiness at ${readinessUrl}`,
102 );
103 yield* HttpClient.get(readinessUrl).pipe(
104 Effect.flatMap((response) =>
105 response.status === 200
106 ? Effect.succeed(response)
107 : Effect.fail(
108 new Error(`Function not ready: ${response.status}`),
109 ),
110 ),
111 Effect.tapError((error) =>
112 Effect.logWarning(
113 `ApplicationSignals test setup: fixture not ready yet (${String(error)})`,
114 ),
115 ),
116 Effect.retry({ schedule: readinessPolicy }),
117 );
118
119 // Warm up the most permission-hungry route: budget reports exercise
120 // both the application-signals grant on the SLO ARN and the dependent
121 // cloudwatch:GetMetricData grant, so a 200 here means the fresh
122 // role's policies have fully propagated.
123 yield* HttpClient.get(`${baseUrl}/budget-report`).pipe(
124 Effect.flatMap((response) =>
125 response.status === 200
126 ? Effect.succeed(response)
127 : Effect.fail(
128 new Error(`IAM not propagated yet: ${response.status}`),
129 ),
130 ),
131 Effect.retry({
132 schedule: Schedule.max([
133 Schedule.fixed("4 seconds"),
134 Schedule.recurs(30),
135 ]),
136 }),
137 );
138 }),
139 { timeout: 240_000 },
140 );
141
142 afterAll(sharedStack.destroy(), { timeout: 120_000 });
143
144 describe("binding registration", () => {
145 test.provider("all 14 capabilities initialize in the runtime", (_stack) =>
146 Effect.gen(function* () {
147 const response = yield* getJson("/bindings");
148 expect((response as any).bound).toHaveLength(14);
149 }),
150 );
151 });
152
153 describe("ListServices", () => {
154 test.provider("lists discovered services", (_stack) =>
155 Effect.gen(function* () {
156 const response = yield* getJson("/services");
157 expect(typeof (response as any).count).toBe("number");
158 }),
159 );
160 });
161
162 describe("GetService", () => {
163 test.provider(
164 "returns an empty service for unknown key attributes",
165 (_stack) =>
166 Effect.gen(function* () {
167 const response = yield* getJson("/service");
168 expect((response as any).discovered).toBe(false);
169 }),
170 );
171 });
172
173 describe("ListServiceDependencies / ListServiceDependents / ListServiceOperations", () => {
174 test.provider("return well-formed empty pages", (_stack) =>
175 Effect.gen(function* () {
176 const dependencies = yield* getJson("/dependencies");
177 const dependents = yield* getJson("/dependents");
178 const operations = yield* getJson("/operations");
179 expect((dependencies as any).count).toBe(0);
180 expect((dependents as any).count).toBe(0);
181 expect((operations as any).count).toBe(0);
182 }),
183 );
184 });
185
186 describe("ListServiceStates", () => {
187 test.provider("lists recent service states", (_stack) =>
188 Effect.gen(function* () {
189 const response = yield* getJson("/states");
190 expect(typeof (response as any).count).toBe("number");
191 }),
192 );
193 });
194
195 describe("ListEntityEvents", () => {
196 test.provider("lists change events for an entity", (_stack) =>
197 Effect.gen(function* () {
198 const response = yield* getJson("/entity-events");
199 expect(typeof (response as any).count).toBe("number");
200 }),
201 );
202 });
203
204 describe("ListAuditFindings", () => {
205 test.provider("lists audit findings for a service target", (_stack) =>
206 Effect.gen(function* () {
207 const response = yield* getJson("/audit-findings");
208 expect(typeof (response as any).count).toBe("number");
209 }),
210 );
211 });
212
213 describe("ListServiceLevelObjectives", () => {
214 test.provider(
215 "lists the account's SLOs including the fixture SLO",
216 (_stack) =>
217 Effect.gen(function* () {
218 const response = yield* getJson("/slos");
219 expect((response as any).count).toBeGreaterThanOrEqual(1);
220 expect(
221 (response as any).names.some((name: string) =>
222 name.toLowerCase().includes("bindingsslo"),
223 ),
224 ).toBe(true);
225 }),
226 );
227 });
228
229 describe("GetServiceLevelObjective", () => {
230 test.provider("reads the bound SLO's configuration", (_stack) =>
231 Effect.gen(function* () {
232 const response = yield* getJson("/slo");
233 expect((response as any).arn).toContain(":slo/");
234 expect((response as any).attainmentGoal).toBe(99);
235 }),
236 );
237 });
238
239 describe("BatchGetServiceLevelObjectiveBudgetReport", () => {
240 test.provider("returns the bound SLO's budget report", (_stack) =>
241 Effect.gen(function* () {
242 const response = yield* getJson("/budget-report");
243 expect((response as any).errors).toBe(0);
244 expect((response as any).reports).toBe(1);
245 expect((response as any).budgetStatus).toBeTruthy();
246 }),
247 );
248 });
249
250 describe("BatchUpdateExclusionWindows / ListServiceLevelObjectiveExclusionWindows", () => {
251 test.provider(
252 "adds, observes, and removes a maintenance window",
253 (_stack) =>
254 Effect.gen(function* () {
255 const before = yield* getJson("/exclusion-windows");
256 expect((before as any).count).toBe(0);
257
258 const response = yield* send(
259 HttpClientRequest.post(`${baseUrl}/exclusion-windows`),
260 ).pipe(Effect.flatMap((r) => r.json));
261 expect((response as any).addErrors).toBe(0);
262 expect((response as any).afterAdd).toBe(1);
263 expect((response as any).removeErrors).toBe(0);
264 expect((response as any).afterRemove).toBe(0);
265 }),
266 );
267 });
268
269 describe("GetInstrumentationConfigurationStatus", () => {
270 test.provider(
271 "returns the bound configuration's (empty) status history",
272 (_stack) =>
273 Effect.gen(function* () {
274 const response = yield* getJson("/ic-status");
275 expect((response as any).events).toBe(0);
276 }),
277 );
278 });
279 },
280);