1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { Region } from "@distilled.cloud/aws/Region";
5import * as s3control from "@distilled.cloud/aws/s3-control";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import S3ControlBindingsFunctionLive, {
13 S3ControlBindingsFunction,
14} from "./fixtures/bindings-handler.ts";
15import S3ControlMrapBindingsFunctionLive, {
16 S3ControlMrapBindingsFunction,
17} from "./fixtures/mrap-bindings-handler.ts";
18
19const testOptions = { providers: AWS.providers() };
20const { test, beforeAll, afterAll } = Test.make(testOptions);
21const sharedStack = Core.scratchStack(testOptions, "S3ControlBindings");
22const mrapStack = Core.scratchStack(testOptions, "S3ControlMrapBindings");
23const ACCOUNT_ID = "391965393224";
24
25// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
26// over 60s on a fresh deploy.
27const readinessPolicy = Schedule.max([
28 Schedule.fixed("2 seconds"),
29 Schedule.recurs(75),
30]);
31
32class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
33 readonly status: number;
34 readonly body: string;
35}> {}
36
37// The shared Lambda fixture occasionally answers a transient 5xx under load
38// (cold re-init, IAM propagation on the freshly attached policy). Retry only
39// 5xx; a genuine 4xx/assertion failure surfaces immediately.
40const send = (request: HttpClientRequest.HttpClientRequest) =>
41 HttpClient.execute(request).pipe(
42 Effect.flatMap((response) =>
43 response.status >= 500
44 ? response.text.pipe(
45 Effect.flatMap((body) =>
46 Effect.fail(
47 new TransientUpstream({ status: response.status, body }),
48 ),
49 ),
50 )
51 : Effect.succeed(response),
52 ),
53 Effect.retry({
54 while: (e): boolean => e._tag === "TransientUpstream",
55 schedule: Schedule.max([
56 Schedule.exponential("500 millis"),
57 Schedule.recurs(6),
58 ]),
59 }),
60 );
61
62const getJson = (baseUrl: string, path: string) =>
63 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
64 Effect.flatMap((r) => r.json),
65 );
66
67const awaitReady = (readinessUrl: string) =>
68 HttpClient.get(readinessUrl).pipe(
69 Effect.flatMap((response) =>
70 response.status === 200
71 ? Effect.succeed(response)
72 : Effect.fail(new Error(`Function not ready: ${response.status}`)),
73 ),
74 Effect.tapError((error) =>
75 Effect.logWarning(
76 `S3Control test setup: fixture not ready yet (${String(error)})`,
77 ),
78 ),
79 Effect.retry({ schedule: readinessPolicy }),
80 );
81
82let baseUrl: string;
83
84describe.sequential(
85 "AWS.S3Control bindings",
86 {
87 tags: [
88 "provider:aws",
89 "provider:aws:iam",
90 "provider:aws:lambda",
91 "provider:aws:s3",
92 "provider:aws:s3control",
93 "live",
94 ],
95 },
96 () => {
97 beforeAll(
98 Effect.gen(function* () {
99 yield* Effect.logInfo(
100 "S3Control test setup: destroying previous resources",
101 );
102 yield* sharedStack.destroy();
103
104 yield* Effect.logInfo("S3Control test setup: deploying fixture");
105 const attrs = yield* sharedStack.deploy(
106 Effect.gen(function* () {
107 return yield* S3ControlBindingsFunction;
108 }).pipe(Effect.provide(S3ControlBindingsFunctionLive)),
109 );
110
111 expect(attrs.functionUrl).toBeTruthy();
112 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
113
114 yield* awaitReady(`${baseUrl}/bindings`);
115 }),
116 { timeout: 240_000 },
117 );
118
119 afterAll(sharedStack.destroy(), { timeout: 240_000 });
120
121 describe("binding registration", () => {
122 test.provider(
123 "all nine capabilities initialize in the runtime",
124 (_stack) =>
125 Effect.gen(function* () {
126 const response = (yield* getJson(baseUrl, "/bindings")) as {
127 bound: string[];
128 };
129 expect(response.bound).toHaveLength(9);
130 }),
131 );
132 });
133
134 describe("GetAccessPoint", () => {
135 test.provider("reads the access point's live configuration", (_stack) =>
136 Effect.gen(function* () {
137 const live = (yield* getJson(baseUrl, "/access-point")) as {
138 name: string | undefined;
139 bucket: string | undefined;
140 networkOrigin: string | undefined;
141 };
142 expect(live.name).toBeTruthy();
143 expect(live.bucket).toBeTruthy();
144 expect(live.networkOrigin).toBe("Internet");
145 }),
146 );
147 });
148
149 describe("GetAccessPointPolicy", () => {
150 test.provider(
151 "typed NoSuchAccessPointPolicy on a policyless access point",
152 (_stack) =>
153 Effect.gen(function* () {
154 const result = (yield* getJson(baseUrl, "/policy")) as {
155 hasPolicy: boolean;
156 };
157 expect(result.hasPolicy).toBe(false);
158 }),
159 );
160 });
161
162 describe("GetAccessPointPolicyStatus", () => {
163 test.provider("reports the access point as non-public", (_stack) =>
164 Effect.gen(function* () {
165 const result = (yield* getJson(baseUrl, "/policy-status")) as {
166 isPublic: boolean;
167 hasPolicy: boolean;
168 };
169 expect(result.isPublic).toBe(false);
170 }),
171 );
172 });
173
174 describe("ListAccessPoints", () => {
175 test.provider("finds the fixture access point by bucket", (_stack) =>
176 Effect.gen(function* () {
177 const live = (yield* getJson(baseUrl, "/access-point")) as {
178 name: string | undefined;
179 };
180 const { names } = (yield* getJson(baseUrl, "/access-points")) as {
181 names: string[];
182 };
183 expect(names).toContain(live.name);
184 }),
185 );
186 });
187
188 describe("CreateJob + DescribeJob + UpdateJobPriority + UpdateJobStatus + ListJobs", () => {
189 test.provider(
190 "runs the batch job loop: create suspended, describe, bump priority, cancel, list",
191 (_stack) =>
192 Effect.gen(function* () {
193 const result = (yield* getJson(baseUrl, "/job-lifecycle")) as {
194 ok: boolean;
195 tag?: string;
196 message?: string;
197 jobId: string;
198 settledStatus: string;
199 priorityOutcome: string;
200 cancelOutcome: string;
201 finalStatus: string;
202 listedJobIds: string[];
203 };
204 expect(
205 result.ok,
206 `job flow failed: ${result.tag}: ${result.message}`,
207 ).toBe(true);
208 expect(result.jobId).toBeTruthy();
209 // The job settles into a stable state (Suspended when awaiting
210 // confirmation; Failed/Complete when the generated manifest is
211 // empty).
212 expect([
213 "Suspended",
214 "Failed",
215 "Complete",
216 "Cancelled",
217 "Ready",
218 "Active",
219 ]).toContain(result.settledStatus);
220 // Both bindings round-trip: either the mutation applied, or AWS
221 // refused the transition with the TYPED tag (which also proves the
222 // distilled JobStatusTransitionForbidden patch end-to-end).
223 expect(["updated", "JobStatusTransitionForbidden"]).toContain(
224 result.priorityOutcome,
225 );
226 expect(result.cancelOutcome).toBeTruthy();
227 expect(result.finalStatus).toBeTruthy();
228 expect(result.listedJobIds).toContain(result.jobId);
229 }),
230 { timeout: 180_000 },
231 );
232 });
233 },
234);
235
236// Multi-Region Access Point provisioning is asynchronous and can consume the
237// entire 240s suite ceiling. Keep this explicitly gated; a hard-killed run
238// must be audited by deterministic physical name before it is retried.
239describe.skipIf(!process.env.AWS_TEST_SLOW).sequential(
240 "AWS.S3Control MRAP route bindings (slow)",
241 {
242 tags: [
243 "provider:aws",
244 "provider:aws:lambda",
245 "provider:aws:s3",
246 "provider:aws:s3control",
247 "live",
248 ],
249 },
250 () => {
251 let mrapBaseUrl: string;
252
253 beforeAll(
254 Effect.gen(function* () {
255 yield* mrapStack.destroy();
256 const attrs = yield* mrapStack.deploy(
257 Effect.gen(function* () {
258 return yield* S3ControlMrapBindingsFunction;
259 }).pipe(Effect.provide(S3ControlMrapBindingsFunctionLive)),
260 );
261 expect(attrs.functionUrl).toBeTruthy();
262 mrapBaseUrl = attrs.functionUrl!.replace(/\/+$/, "");
263 yield* awaitReady(`${mrapBaseUrl}/routes`);
264 }),
265 { timeout: 240_000 },
266 );
267
268 afterAll(mrapStack.destroy(), { timeout: 240_000 });
269
270 describe("GetMultiRegionAccessPointRoutes + SubmitMultiRegionAccessPointRoutes", () => {
271 test.provider(
272 "reads the routes and re-submits them at 100%",
273 (_stack) =>
274 Effect.gen(function* () {
275 const before = (yield* getJson(mrapBaseUrl, "/routes")) as {
276 mrapName: string;
277 routes: { region: string; trafficDialPercentage: number }[];
278 };
279 expect(before.routes.length).toBeGreaterThan(0);
280
281 const after = (yield* getJson(mrapBaseUrl, "/dial-100")) as {
282 routes: { region: string; trafficDialPercentage: number }[];
283 };
284 expect(after.routes.length).toBe(before.routes.length);
285 for (const route of after.routes) {
286 expect(route.trafficDialPercentage).toBe(100);
287 }
288
289 // Verify the binding's mutation independently of the Lambda
290 // response, through the distilled S3Control client.
291 const live = yield* s3control
292 .getMultiRegionAccessPointRoutes({
293 AccountId: ACCOUNT_ID,
294 Mrap: before.mrapName,
295 })
296 .pipe(Effect.provideService(Region, Effect.succeed("us-west-2")));
297 expect(live.Routes?.length).toBe(before.routes.length);
298 for (const route of live.Routes ?? []) {
299 expect(route.TrafficDialPercentage).toBe(100);
300 }
301 }),
302 { timeout: 120_000 },
303 );
304 });
305 },
306);