1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as IAM from "@distilled.cloud/aws/iam";
5import * as S3 from "@distilled.cloud/aws/s3";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Schedule from "effect/Schedule";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import HeadObjectTestFunctionLive, {
16 HeadObjectTestFunction,
17} from "./fixtures/head-object-handler.ts";
18import PresignGetOnlyTestFunctionLive, {
19 PresignGetOnlyTestFunction,
20} from "./fixtures/presign-get-only-handler.ts";
21import S3PresignTestFunctionLive, {
22 S3PresignTestFunction,
23} from "./fixtures/presign-handler";
24
25const testOptions = { providers: AWS.providers() };
26const { test, beforeAll, afterAll } = Test.make(testOptions);
27const sharedStack = Core.scratchStack(
28 testOptions,
29 "S3Bindings",
30 "test/AWS/S3/Bindings.test.ts",
31);
32
33// Lambda function URL cold-start (DNS, IAM propagation, init) can take
34// well over 60s on a fresh deploy under parallel-suite load. Budget ~150s
35// of readiness polling so we don't fail the whole suite on a slow init.
36const readinessPolicy = Schedule.max([
37 Schedule.fixed("2 seconds"),
38 Schedule.recurs(75),
39]);
40
41let baseUrl: string;
42let bucketName: string;
43let headUrl: string;
44let headRoleName: string;
45let headBucket: { bucketName: string; bucketArn: string };
46let getOnlyUrl: string;
47let getOnlyRoleName: string;
48let getOnlyBucket: { bucketName: string; bucketArn: string };
49
50const policyDocument = Schema.fromJsonString(
51 Schema.Struct({
52 Version: Schema.optional(Schema.String),
53 Id: Schema.optional(Schema.String),
54 Statement: Schema.Array(
55 Schema.Struct({
56 Sid: Schema.optional(Schema.String),
57 Effect: Schema.String,
58 Action: Schema.Union([Schema.String, Schema.Array(Schema.String)]),
59 Resource: Schema.Union([Schema.String, Schema.Array(Schema.String)]),
60 }),
61 ),
62 }),
63);
64
65const s3RolePermissions = Effect.fn(function* (roleName: string) {
66 const attached = yield* IAM.listAttachedRolePolicies
67 .pages({ RoleName: roleName })
68 .pipe(Stream.runCollect);
69 expect(
70 attached
71 .flatMap((page) => page.AttachedPolicies ?? [])
72 .map((policy) => policy.PolicyArn),
73 ).toEqual([
74 "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
75 ]);
76 const pages = yield* IAM.listRolePolicies
77 .pages({ RoleName: roleName })
78 .pipe(Stream.runCollect);
79 const policies = yield* Effect.forEach(
80 pages.flatMap((page) => page.PolicyNames),
81 Effect.fn(function* (PolicyName) {
82 const policy = yield* IAM.getRolePolicy({
83 RoleName: roleName,
84 PolicyName,
85 });
86 const decoded = yield* Effect.try(() =>
87 decodeURIComponent(policy.PolicyDocument),
88 );
89 return yield* Schema.decodeUnknownEffect(policyDocument, {
90 onExcessProperty: "error",
91 })(decoded);
92 }),
93 );
94 return policies
95 .flatMap((policy) =>
96 policy.Statement.flatMap((statement) => {
97 const actions =
98 typeof statement.Action === "string"
99 ? [statement.Action]
100 : statement.Action;
101 const resources =
102 typeof statement.Resource === "string"
103 ? [statement.Resource]
104 : statement.Resource;
105 return actions.flatMap((action) =>
106 resources.map((resource) => ({
107 effect: statement.Effect,
108 action,
109 resource,
110 })),
111 );
112 }),
113 )
114 .sort(
115 (a, b) =>
116 a.action.localeCompare(b.action) ||
117 a.resource.localeCompare(b.resource),
118 );
119});
120
121const readBucketInfo = (url: string) =>
122 HttpClient.get(`${url}/info`).pipe(
123 Effect.flatMap((response) =>
124 response.status === 200
125 ? response.json
126 : Effect.fail(new Error(`Function not ready: ${response.status}`)),
127 ),
128 Effect.flatMap(
129 Schema.decodeUnknownEffect(
130 Schema.Struct({
131 bucketName: Schema.String,
132 bucketArn: Schema.String,
133 }),
134 ),
135 ),
136 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 10 }),
137 );
138
139class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
140 readonly status: number;
141 readonly body: string;
142}> {}
143
144// The shared Lambda fixture occasionally answers a transient 5xx under
145// parallel load (cold re-init). Retry only 5xx; a genuine 4xx/assertion
146// failure is surfaced immediately.
147const send = (request: HttpClientRequest.HttpClientRequest) =>
148 HttpClient.execute(request).pipe(
149 Effect.flatMap((response) =>
150 response.status >= 500
151 ? response.text.pipe(
152 Effect.flatMap((body) =>
153 Effect.fail(
154 new TransientUpstream({ status: response.status, body }),
155 ),
156 ),
157 )
158 : Effect.succeed(response),
159 ),
160 Effect.retry({
161 while: (e) => e._tag === "TransientUpstream",
162 schedule: Schedule.max([
163 Schedule.exponential("500 millis"),
164 Schedule.recurs(6),
165 ]),
166 }),
167 );
168
169// Drive a fixture route and parse its JSON response. The Lambda role's
170// inline policy can take a few seconds to propagate after deploy, so a
171// 403-shaped AccessDenied tag from a probe route (or a raw 403) is retried
172// by the caller where relevant; transient 5xx are retried by `send`.
173const getJson = <T>(path: string) =>
174 Effect.gen(function* () {
175 const response = yield* send(HttpClientRequest.get(`${baseUrl}${path}`));
176 expect(response.status).toBe(200);
177 return (yield* response.json) as T;
178 });
179
180const route = (pathname: string, params: Record<string, string>) =>
181 `${pathname}?${new URLSearchParams(params).toString()}`;
182
183// Probe routes answer `{ tag }` — retry while IAM propagation still yields
184// AccessDenied so we assert the *expected* typed platform rejection.
185const getTag = (path: string) =>
186 getJson<{ tag: string }>(path).pipe(
187 Effect.map((body) => body.tag),
188 Effect.flatMap((tag): Effect.Effect<string, IamNotPropagated> =>
189 tag === "AccessDenied"
190 ? Effect.fail(new IamNotPropagated({ status: 403, body: tag }))
191 : Effect.succeed(tag),
192 ),
193 Effect.retry({
194 while: (e): boolean => e._tag === "IamNotPropagated",
195 schedule: Schedule.max([
196 Schedule.exponential("1 second"),
197 Schedule.recurs(8),
198 ]),
199 }),
200 );
201
202// Mint a presigned URL via the deployed Lambda fixture.
203const presign = (
204 op: "presign-get" | "presign-put",
205 params: { key: string; expiresIn?: number; contentType?: string },
206) =>
207 Effect.gen(function* () {
208 const search = new URLSearchParams({ key: params.key });
209 if (params.expiresIn !== undefined) {
210 search.set("expiresIn", String(params.expiresIn));
211 }
212 if (params.contentType !== undefined) {
213 search.set("contentType", params.contentType);
214 }
215 const response = yield* send(
216 HttpClientRequest.get(`${baseUrl}/${op}?${search.toString()}`),
217 );
218 expect(response.status).toBe(200);
219 const body = (yield* response.json) as { url: string };
220 expect(body.url).toContain("X-Amz-Signature=");
221 return body.url;
222 });
223
224class IamNotPropagated extends Data.TaggedError("IamNotPropagated")<{
225 readonly status: number;
226 readonly body: string;
227}> {}
228
229class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
230
231// Out-of-band assert-gone after the final destroy: retry while headBucket
232// still succeeds (S3 delete visibility is eventually consistent), settle on
233// the typed NotFound.
234const assertBucketDeleted = Effect.fn(function* (name: string) {
235 yield* S3.headBucket({ Bucket: name }).pipe(
236 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
237 Effect.retry({
238 while: (e) => e._tag === "BucketStillExists",
239 schedule: Schedule.max([Schedule.exponential(100), Schedule.recurs(10)]),
240 }),
241 Effect.catchTag("NotFound", () => Effect.void),
242 );
243});
244
245// The Lambda role's inline policy can take a few seconds to propagate after
246// deploy — a structurally valid presigned URL answers 403 until it does.
247// Retry 403s on a bounded schedule; any other failure surfaces immediately.
248const sendPresigned = (request: HttpClientRequest.HttpClientRequest) =>
249 HttpClient.execute(request).pipe(
250 Effect.flatMap((response) =>
251 response.status === 403
252 ? response.text.pipe(
253 Effect.flatMap((body) =>
254 Effect.fail(
255 new IamNotPropagated({ status: response.status, body }),
256 ),
257 ),
258 )
259 : Effect.succeed(response),
260 ),
261 Effect.retry({
262 while: (e) => e._tag === "IamNotPropagated",
263 schedule: Schedule.max([
264 Schedule.exponential("1 second"),
265 Schedule.recurs(8),
266 ]),
267 }),
268 );
269
270describe(
271 "S3 Bindings",
272 { tags: ["provider:aws", "provider:aws:lambda", "provider:aws:s3", "live"] },
273 () => {
274 beforeAll(
275 Effect.gen(function* () {
276 yield* Effect.logInfo("S3 test setup: destroying previous resources");
277 yield* sharedStack.destroy();
278
279 yield* Effect.logInfo("S3 test setup: deploying presign fixture");
280 const functions = yield* sharedStack.deploy(
281 Effect.gen(function* () {
282 return {
283 shared: yield* S3PresignTestFunction,
284 head: yield* HeadObjectTestFunction,
285 getOnly: yield* PresignGetOnlyTestFunction,
286 };
287 }).pipe(
288 Effect.provide(
289 Layer.mergeAll(
290 S3PresignTestFunctionLive,
291 HeadObjectTestFunctionLive,
292 PresignGetOnlyTestFunctionLive,
293 ),
294 ),
295 ),
296 );
297
298 expect(functions.shared.functionUrl).toBeTruthy();
299 baseUrl = functions.shared.functionUrl!.replace(/\/+$/, "");
300 headUrl = functions.head.functionUrl!.replace(/\/+$/, "");
301 headRoleName = functions.head.roleName;
302 getOnlyUrl = functions.getOnly.functionUrl!.replace(/\/+$/, "");
303 getOnlyRoleName = functions.getOnly.roleName;
304 headBucket = yield* readBucketInfo(headUrl);
305 getOnlyBucket = yield* readBucketInfo(getOnlyUrl);
306 const readinessUrl = `${baseUrl}/bucket-name`;
307
308 yield* Effect.logInfo(
309 `S3 test setup: probing readiness at ${readinessUrl}`,
310 );
311 // The fixture answers 503 until the runtime hydrates resource
312 // Outputs (first-event race after a cold start) — keep retrying
313 // until it serves the bucket name.
314 const ready = yield* HttpClient.get(readinessUrl).pipe(
315 Effect.flatMap((response) =>
316 response.status === 200
317 ? response.json
318 : Effect.fail(
319 new Error(`Function not ready: ${response.status}`),
320 ),
321 ),
322 Effect.tapError((error) =>
323 Effect.logWarning(
324 `S3 test setup: fixture not ready yet (${String(error)})`,
325 ),
326 ),
327 Effect.retry({ schedule: readinessPolicy }),
328 );
329 bucketName = (ready as { bucketName: string }).bucketName;
330 expect(bucketName).toBeTruthy();
331 yield* Effect.logInfo(
332 `S3 test setup: fixture ready (bucket ${bucketName})`,
333 );
334 }),
335 { timeout: 240_000 },
336 );
337
338 afterAll(
339 Effect.gen(function* () {
340 yield* sharedStack.destroy();
341 // Prove the trailing destroy really removed the fixture bucket
342 // (bucketName is captured in beforeAll; skip if setup never got there).
343 // afterAll lacks the providers layer test bodies get, so provide it for
344 // the out-of-band distilled call.
345 for (const name of [
346 bucketName,
347 headBucket?.bucketName,
348 getOnlyBucket?.bucketName,
349 ]) {
350 if (name) {
351 yield* Core.withProviders(
352 assertBucketDeleted(name),
353 testOptions,
354 "S3Bindings",
355 );
356 }
357 }
358 }),
359 { timeout: 120_000 },
360 );
361
362 describe("PresignPutObject", () => {
363 test.provider(
364 "uploads a body via a Lambda-minted presigned PUT URL",
365 (_stack) =>
366 Effect.gen(function* () {
367 const key = "presign/upload.txt";
368 const body = "uploaded through a presigned PUT URL";
369
370 const url = yield* presign("presign-put", {
371 key,
372 contentType: "text/plain",
373 });
374
375 const putResponse = yield* sendPresigned(
376 HttpClientRequest.put(url).pipe(
377 HttpClientRequest.bodyText(body, "text/plain"),
378 ),
379 );
380 expect(putResponse.status).toBe(200);
381
382 // out-of-band verification via distilled — the object really landed
383 const head = yield* S3.headObject({ Bucket: bucketName, Key: key });
384 expect(head.ContentLength).toBe(body.length);
385 expect(head.ContentType).toBe("text/plain");
386 }),
387 { timeout: 120_000 },
388 );
389
390 test.provider(
391 "rejects an upload whose Content-Type does not match the signature",
392 (_stack) =>
393 Effect.gen(function* () {
394 const url = yield* presign("presign-put", {
395 key: "presign/mismatched.txt",
396 contentType: "text/plain",
397 });
398
399 // Signed for text/plain but sent as application/json — the
400 // signature no longer matches, so S3 must reject it.
401 const response = yield* HttpClient.execute(
402 HttpClientRequest.put(url).pipe(
403 HttpClientRequest.bodyText("{}", "application/json"),
404 ),
405 );
406 expect(response.status).toBe(403);
407 }),
408 { timeout: 120_000 },
409 );
410 });
411
412 describe("PresignGetObject", () => {
413 test.provider(
414 "round-trips a body through presigned PUT and GET URLs",
415 (_stack) =>
416 Effect.gen(function* () {
417 const key = "presign/round-trip.txt";
418 const body = "presigned round trip payload";
419
420 const putUrl = yield* presign("presign-put", {
421 key,
422 contentType: "text/plain",
423 });
424 const putResponse = yield* sendPresigned(
425 HttpClientRequest.put(putUrl).pipe(
426 HttpClientRequest.bodyText(body, "text/plain"),
427 ),
428 );
429 expect(putResponse.status).toBe(200);
430
431 const getUrl = yield* presign("presign-get", { key });
432 const getResponse = yield* sendPresigned(
433 HttpClientRequest.get(getUrl),
434 );
435 expect(getResponse.status).toBe(200);
436 expect(yield* getResponse.text).toBe(body);
437 }),
438 { timeout: 120_000 },
439 );
440
441 test.provider(
442 "overrides the response Content-Type via contentType",
443 (_stack) =>
444 Effect.gen(function* () {
445 const key = "presign/override.txt";
446
447 // seed the object out-of-band via distilled
448 yield* S3.putObject({
449 Bucket: bucketName,
450 Key: key,
451 Body: "override me",
452 ContentType: "text/plain",
453 });
454
455 const getUrl = yield* presign("presign-get", {
456 key,
457 contentType: "application/octet-stream",
458 });
459 const getResponse = yield* sendPresigned(
460 HttpClientRequest.get(getUrl),
461 );
462 expect(getResponse.status).toBe(200);
463 expect(getResponse.headers["content-type"]).toBe(
464 "application/octet-stream",
465 );
466 expect(yield* getResponse.text).toBe("override me");
467 }),
468 { timeout: 120_000 },
469 );
470
471 test.provider(
472 "expired presigned URLs are rejected",
473 (_stack) =>
474 Effect.gen(function* () {
475 const key = "presign/expiring.txt";
476 yield* S3.putObject({
477 Bucket: bucketName,
478 Key: key,
479 Body: "short lived",
480 });
481
482 const getUrl = yield* presign("presign-get", {
483 key,
484 expiresIn: 1,
485 });
486
487 // Poll until S3 reports the URL expired (bounded — expiry is 1s,
488 // allow a little clock skew between the Lambda signer and S3).
489 const status = yield* Effect.gen(function* () {
490 const response = yield* HttpClient.execute(
491 HttpClientRequest.get(getUrl),
492 );
493 return response.status;
494 }).pipe(
495 Effect.repeat({
496 schedule: Schedule.spaced("2 seconds"),
497 until: (status) => status === 403,
498 times: 8,
499 }),
500 );
501 expect(status).toBe(403);
502 }),
503 { timeout: 120_000 },
504 );
505 });
506
507 describe("HeadObject", () => {
508 test.provider(
509 "grants only object reads and bucket-scoped listing",
510 () =>
511 Effect.gen(function* () {
512 expect(yield* s3RolePermissions(headRoleName)).toEqual([
513 {
514 effect: "Allow",
515 action: "s3:GetObject",
516 resource: `${headBucket.bucketArn}/*`,
517 },
518 {
519 effect: "Allow",
520 action: "s3:GetObjectVersion",
521 resource: `${headBucket.bucketArn}/*`,
522 },
523 {
524 effect: "Allow",
525 action: "s3:ListBucket",
526 resource: headBucket.bucketArn,
527 },
528 ]);
529 }),
530 { tags: ["provider:aws:iam"] },
531 );
532
533 test.provider(
534 "reads current and non-current metadata with only HeadObject bound",
535 () =>
536 Effect.gen(function* () {
537 const key = "head/versions.txt";
538 const oldBody = "original metadata";
539 const currentBody =
540 "updated metadata with a different content length";
541 const oldVersion = yield* S3.putObject({
542 Bucket: headBucket.bucketName,
543 Key: key,
544 Body: oldBody,
545 ContentType: "text/plain",
546 });
547 const currentVersion = yield* S3.putObject({
548 Bucket: headBucket.bucketName,
549 Key: key,
550 Body: currentBody,
551 ContentType: "text/markdown",
552 });
553 expect(oldVersion.VersionId).toBeTruthy();
554 expect(currentVersion.VersionId).not.toBe(oldVersion.VersionId);
555 for (const [params, expected] of [
556 [
557 { key },
558 {
559 contentLength: currentBody.length,
560 contentType: "text/markdown",
561 versionId: currentVersion.VersionId,
562 },
563 ],
564 [
565 { key, versionId: oldVersion.VersionId! },
566 {
567 contentLength: oldBody.length,
568 contentType: "text/plain",
569 versionId: oldVersion.VersionId,
570 },
571 ],
572 ] as const) {
573 const response = yield* send(
574 HttpClientRequest.get(`${headUrl}${route("/head", params)}`),
575 );
576 expect(response.status).toBe(200);
577 expect(yield* response.json).toEqual(expected);
578 }
579 }),
580 );
581
582 test.provider(
583 "returns typed NotFound for a missing key with only HeadObject bound",
584 () =>
585 Effect.gen(function* () {
586 const response = yield* send(
587 HttpClientRequest.get(`${headUrl}/head?key=head/never-created`),
588 );
589 expect(response.status).toBe(404);
590 expect(yield* response.json).toEqual({ tag: "NotFound" });
591 }),
592 );
593 });
594
595 describe("PresignGetObject", () => {
596 test.provider(
597 "pins downloads across overwrites and signs versioned response overrides and expiry",
598 () =>
599 Effect.gen(function* () {
600 const key = "presign/pinned-before-overwrite.txt";
601 const old = yield* S3.putObject({
602 Bucket: getOnlyBucket.bucketName,
603 Key: key,
604 Body: "before overwrite",
605 });
606 const mint = (versionId?: string, expiresIn = 900) =>
607 Effect.gen(function* () {
608 const path = yield* Effect.sync(() =>
609 route("/presign", {
610 key,
611 expiresIn: String(expiresIn),
612 contentType: "text/markdown",
613 ...(versionId === undefined ? {} : { versionId }),
614 }),
615 );
616 const response = yield* send(
617 HttpClientRequest.get(`${getOnlyUrl}${path}`),
618 );
619 expect(response.status).toBe(200);
620 return (yield* response.json.pipe(
621 Effect.flatMap(
622 Schema.decodeUnknownEffect(
623 Schema.Struct({ url: Schema.String }),
624 ),
625 ),
626 )).url;
627 });
628 const pinned = yield* mint(old.VersionId!);
629 const current = yield* mint();
630 const latest = yield* S3.putObject({
631 Bucket: getOnlyBucket.bucketName,
632 Key: key,
633 Body: "after overwrite",
634 });
635 for (const [url, body, version] of [
636 [pinned, "before overwrite", old.VersionId],
637 [current, "after overwrite", latest.VersionId],
638 ] as const) {
639 const response = yield* sendPresigned(HttpClientRequest.get(url));
640 expect(yield* response.text).toBe(body);
641 expect(response.headers["x-amz-version-id"]).toBe(version);
642 expect(response.headers["content-type"]).toBe("text/markdown");
643 }
644 const expiring = yield* mint(old.VersionId!, 1);
645 const status = yield* send(HttpClientRequest.get(expiring)).pipe(
646 Effect.map((response) => response.status),
647 Effect.repeat({
648 schedule: Schedule.spaced("2 seconds"),
649 times: 8,
650 until: (status) => status === 403,
651 }),
652 );
653 expect(status).toBe(403);
654 }),
655 );
656
657 test.provider(
658 "grants only current and versioned object reads",
659 () =>
660 Effect.gen(function* () {
661 expect(yield* s3RolePermissions(getOnlyRoleName)).toEqual([
662 {
663 effect: "Allow",
664 action: "s3:GetObject",
665 resource: `${getOnlyBucket.bucketArn}/*`,
666 },
667 {
668 effect: "Allow",
669 action: "s3:GetObjectVersion",
670 resource: `${getOnlyBucket.bucketArn}/*`,
671 },
672 ]);
673 }),
674 { tags: ["provider:aws:iam"] },
675 );
676
677 test.provider(
678 "downloads current and older versions with only PresignGetObject bound and rejects version tampering",
679 () =>
680 Effect.gen(function* () {
681 const key = "presign/versions/a b+%?#/雪.txt";
682 const oldBody = "previous value from an isolated presigner";
683 const currentBody = "current value with different bytes and length";
684 yield* S3.putBucketVersioning({
685 Bucket: getOnlyBucket.bucketName,
686 VersioningConfiguration: { Status: "Suspended" },
687 });
688 yield* S3.putObject({
689 Bucket: getOnlyBucket.bucketName,
690 Key: key,
691 Body: "null version",
692 });
693 yield* S3.putBucketVersioning({
694 Bucket: getOnlyBucket.bucketName,
695 VersioningConfiguration: { Status: "Enabled" },
696 });
697 const oldVersion = yield* S3.putObject({
698 Bucket: getOnlyBucket.bucketName,
699 Key: key,
700 Body: oldBody,
701 });
702 const currentVersion = yield* S3.putObject({
703 Bucket: getOnlyBucket.bucketName,
704 Key: key,
705 Body: currentBody,
706 });
707 expect(oldVersion.VersionId).toBeTruthy();
708 expect(currentVersion.VersionId).toBeTruthy();
709 expect(currentVersion.VersionId).not.toBe(oldVersion.VersionId);
710 for (const [versionId, body, expectedVersionId] of [
711 [undefined, currentBody, currentVersion.VersionId],
712 [
713 currentVersion.VersionId!,
714 currentBody,
715 currentVersion.VersionId,
716 ],
717 [oldVersion.VersionId!, oldBody, oldVersion.VersionId],
718 ["null", "null version", "null"],
719 ] as const) {
720 const path = yield* Effect.sync(() =>
721 route("/presign", {
722 key,
723 ...(versionId === undefined ? {} : { versionId }),
724 }),
725 );
726 const signed = yield* send(
727 HttpClientRequest.get(`${getOnlyUrl}${path}`),
728 );
729 expect(signed.status).toBe(200);
730 const result = yield* signed.json.pipe(
731 Effect.flatMap(
732 Schema.decodeUnknownEffect(
733 Schema.Struct({ url: Schema.String }),
734 ),
735 ),
736 );
737 const parsed = yield* Effect.sync(() => new URL(result.url));
738 expect(parsed.searchParams.get("versionId")).toBe(
739 versionId ?? null,
740 );
741 expect(parsed.searchParams.get("X-Amz-Signature")).toMatch(
742 /^[0-9a-f]{64}$/,
743 );
744 expect(
745 yield* Effect.sync(() => decodeURIComponent(parsed.pathname)),
746 ).toBe(`/${key}`);
747 expect(parsed.hash).toBe("");
748 if (versionId === oldVersion.VersionId) {
749 yield* S3.deleteObject({
750 Bucket: getOnlyBucket.bucketName,
751 Key: key,
752 });
753 }
754 const downloaded = yield* sendPresigned(
755 HttpClientRequest.get(result.url),
756 );
757 expect(downloaded.status).toBe(200);
758 expect(yield* downloaded.text).toBe(body);
759 expect(downloaded.headers["x-amz-version-id"]).toBe(
760 expectedVersionId,
761 );
762
763 const tamperedUrl = yield* Effect.sync(() => {
764 const url = new URL(result.url);
765 url.searchParams.set(
766 "versionId",
767 versionId === oldVersion.VersionId
768 ? currentVersion.VersionId!
769 : oldVersion.VersionId!,
770 );
771 return url.toString();
772 });
773 const tampered = yield* send(HttpClientRequest.get(tamperedUrl));
774 expect(tampered.status).toBe(403);
775 expect(yield* tampered.text).toContain(
776 "<Code>SignatureDoesNotMatch</Code>",
777 );
778
779 if (versionId !== undefined) {
780 const withoutVersion = yield* Effect.sync(() => {
781 const url = new URL(result.url);
782 url.searchParams.delete("versionId");
783 return url.toString();
784 });
785 const removed = yield* send(
786 HttpClientRequest.get(withoutVersion),
787 );
788 expect(removed.status).toBe(403);
789 expect(yield* removed.text).toContain(
790 "<Code>SignatureDoesNotMatch</Code>",
791 );
792 }
793 }
794 }),
795 );
796 });
797
798 const seed = (key: string, body: string) =>
799 S3.putObject({ Bucket: bucketName, Key: key, Body: body });
800
801 describe("DeleteObjects", () => {
802 test.provider(
803 "batch-deletes several objects in one call",
804 (_stack) =>
805 Effect.gen(function* () {
806 yield* seed("batch/one.txt", "first");
807 yield* seed("batch/two.txt", "second");
808
809 const result = yield* getJson<{
810 deleted: string[];
811 errors: string[];
812 }>(
813 route("/delete-objects", { keys: "batch/one.txt,batch/two.txt" }),
814 );
815 expect(result.errors).toEqual([]);
816 expect(result.deleted).toContain("batch/one.txt");
817 expect(result.deleted).toContain("batch/two.txt");
818
819 // out-of-band verification via distilled — the objects are gone
820 const head = yield* S3.headObject({
821 Bucket: bucketName,
822 Key: "batch/one.txt",
823 }).pipe(
824 Effect.map(() => "found" as const),
825 Effect.catchTag("NotFound", () =>
826 Effect.succeed("not-found" as const),
827 ),
828 );
829 expect(head).toBe("not-found");
830 }),
831 { timeout: 120_000 },
832 );
833 });
834
835 describe("PutObjectTagging", () => {
836 test.provider(
837 "replaces an object's tag set",
838 (_stack) =>
839 Effect.gen(function* () {
840 yield* seed("tagging/put.txt", "tag me");
841
842 yield* getJson<{ ok: boolean }>(
843 route("/put-tagging", {
844 key: "tagging/put.txt",
845 tagKey: "env",
846 tagValue: "prod",
847 }),
848 );
849
850 // out-of-band verification via distilled
851 const tags = yield* S3.getObjectTagging({
852 Bucket: bucketName,
853 Key: "tagging/put.txt",
854 });
855 expect(tags.TagSet).toEqual([{ Key: "env", Value: "prod" }]);
856 }),
857 { timeout: 120_000 },
858 );
859 });
860
861 describe("GetObjectTagging", () => {
862 test.provider(
863 "reads an object's tag set",
864 (_stack) =>
865 Effect.gen(function* () {
866 yield* seed("tagging/get.txt", "tagged");
867 yield* S3.putObjectTagging({
868 Bucket: bucketName,
869 Key: "tagging/get.txt",
870 Tagging: { TagSet: [{ Key: "team", Value: "alchemy" }] },
871 });
872
873 const result = yield* getJson<{ tags: Record<string, string> }>(
874 route("/get-tagging", { key: "tagging/get.txt" }),
875 );
876 expect(result.tags).toEqual({ team: "alchemy" });
877 }),
878 { timeout: 120_000 },
879 );
880 });
881
882 describe("DeleteObjectTagging", () => {
883 test.provider(
884 "removes an object's entire tag set",
885 (_stack) =>
886 Effect.gen(function* () {
887 yield* seed("tagging/delete.txt", "untag me");
888 yield* S3.putObjectTagging({
889 Bucket: bucketName,
890 Key: "tagging/delete.txt",
891 Tagging: { TagSet: [{ Key: "ephemeral", Value: "yes" }] },
892 });
893
894 yield* getJson<{ ok: boolean }>(
895 route("/delete-tagging", { key: "tagging/delete.txt" }),
896 );
897
898 // out-of-band verification via distilled
899 const tags = yield* S3.getObjectTagging({
900 Bucket: bucketName,
901 Key: "tagging/delete.txt",
902 });
903 expect(tags.TagSet ?? []).toEqual([]);
904 }),
905 { timeout: 120_000 },
906 );
907 });
908
909 describe("GetObjectAttributes", () => {
910 test.provider(
911 "reads object size and storage class without the body",
912 (_stack) =>
913 Effect.gen(function* () {
914 const body = "attribute probe body";
915 yield* seed("attrs/object.txt", body);
916
917 const result = yield* getJson<{
918 size: number;
919 storageClass: string;
920 }>(route("/attributes", { key: "attrs/object.txt" }));
921 expect(result.size).toBe(body.length);
922 expect(result.storageClass).toBe("STANDARD");
923 }),
924 { timeout: 120_000 },
925 );
926 });
927
928 describe("ListObjectVersions", () => {
929 test.provider(
930 "lists versions under a prefix",
931 (_stack) =>
932 Effect.gen(function* () {
933 yield* seed("versions/a.txt", "v1");
934
935 const result = yield* getJson<{
936 versions: string[];
937 deleteMarkers: number;
938 }>(route("/versions", { prefix: "versions/" }));
939 expect(result.versions).toContain("versions/a.txt");
940 }),
941 { timeout: 120_000 },
942 );
943 });
944
945 describe("ListParts", () => {
946 test.provider(
947 "lists the uploaded parts of an in-progress multipart upload",
948 (_stack) =>
949 Effect.gen(function* () {
950 const result = yield* getJson<{
951 parts: number[];
952 uploads: string[];
953 }>(route("/multipart-list", { key: "mpu/list.bin" }));
954 expect(result.parts).toEqual([1]);
955 }),
956 { timeout: 120_000 },
957 );
958 });
959
960 describe("ListMultipartUploads", () => {
961 test.provider(
962 "lists in-progress multipart uploads",
963 (_stack) =>
964 Effect.gen(function* () {
965 const result = yield* getJson<{
966 parts: number[];
967 uploads: string[];
968 }>(route("/multipart-list", { key: "mpu/uploads.bin" }));
969 expect(result.uploads).toContain("mpu/uploads.bin");
970 }),
971 { timeout: 120_000 },
972 );
973 });
974
975 describe("UploadPartCopy", () => {
976 test.provider(
977 "assembles an object from a copied part",
978 (_stack) =>
979 Effect.gen(function* () {
980 const body = "upload part copy source payload";
981 yield* seed("mpu/src.txt", body);
982
983 const result = yield* getJson<{ etag: string | undefined }>(
984 route("/multipart-copy", {
985 src: "mpu/src.txt",
986 dest: "mpu/dest.txt",
987 }),
988 );
989 expect(result.etag).toBeTruthy();
990
991 // out-of-band verification via distilled — the copy landed intact
992 const head = yield* S3.headObject({
993 Bucket: bucketName,
994 Key: "mpu/dest.txt",
995 });
996 expect(head.ContentLength).toBe(body.length);
997 }),
998 { timeout: 120_000 },
999 );
1000 });
1001
1002 describe("RestoreObject", () => {
1003 test.provider(
1004 "restore of a STANDARD object fails with the typed InvalidObjectState",
1005 (_stack) =>
1006 Effect.gen(function* () {
1007 yield* seed("restore/std.txt", "not archived");
1008
1009 const tag = yield* getTag(
1010 route("/restore", { key: "restore/std.txt" }),
1011 );
1012 // STANDARD objects are not restorable — the binding must surface
1013 // the *typed* platform rejection, proving IAM + wiring works.
1014 expect([
1015 "InvalidObjectState",
1016 "ObjectAlreadyInActiveTierError",
1017 ]).toContain(tag);
1018 }),
1019 { timeout: 120_000 },
1020 );
1021 });
1022
1023 describe("GetObjectRetention", () => {
1024 test.provider(
1025 "returns the typed InvalidRequest on a bucket without Object Lock",
1026 (_stack) =>
1027 Effect.gen(function* () {
1028 yield* seed("lock/get-retention.txt", "no lock");
1029 const tag = yield* getTag(
1030 route("/retention", { key: "lock/get-retention.txt" }),
1031 );
1032 expect(tag).toBe("InvalidRequest");
1033 }),
1034 { timeout: 120_000 },
1035 );
1036 });
1037
1038 describe("PutObjectRetention", () => {
1039 test.provider(
1040 "returns the typed InvalidRequest on a bucket without Object Lock",
1041 (_stack) =>
1042 Effect.gen(function* () {
1043 yield* seed("lock/put-retention.txt", "no lock");
1044 const tag = yield* getTag(
1045 route("/retention-put", { key: "lock/put-retention.txt" }),
1046 );
1047 expect(tag).toBe("InvalidRequest");
1048 }),
1049 { timeout: 120_000 },
1050 );
1051 });
1052
1053 describe("GetObjectLegalHold", () => {
1054 test.provider(
1055 "returns the typed InvalidRequest on a bucket without Object Lock",
1056 (_stack) =>
1057 Effect.gen(function* () {
1058 yield* seed("lock/get-hold.txt", "no lock");
1059 const tag = yield* getTag(
1060 route("/legal-hold", { key: "lock/get-hold.txt" }),
1061 );
1062 expect(tag).toBe("InvalidRequest");
1063 }),
1064 { timeout: 120_000 },
1065 );
1066 });
1067
1068 describe("PutObjectLegalHold", () => {
1069 test.provider(
1070 "returns the typed InvalidRequest on a bucket without Object Lock",
1071 (_stack) =>
1072 Effect.gen(function* () {
1073 yield* seed("lock/put-hold.txt", "no lock");
1074 const tag = yield* getTag(
1075 route("/legal-hold-put", { key: "lock/put-hold.txt" }),
1076 );
1077 expect(tag).toBe("InvalidRequest");
1078 }),
1079 { timeout: 120_000 },
1080 );
1081 });
1082 },
1083);