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 Schedule from "effect/Schedule";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14import VersionedObjectFunctionLive, {
15 VersionedObjectFunction,
16} from "./fixtures/versioned-object-handler.ts";
17
18const options = { providers: AWS.providers() };
19const { test, beforeAll, afterAll } = Test.make(options);
20const stack = Core.scratchStack(
21 options,
22 "S3VersionedObjects",
23 "test/AWS/S3/VersionedObjects.test.ts",
24);
25const BucketInfo = Schema.Struct({
26 bucketName: Schema.String,
27 bucketArn: Schema.String,
28});
29let buckets: Record<string, typeof BucketInfo.Type> = {};
30let url: string;
31let roleName: string;
32class NotReady extends Data.TaggedError("NotReady")<{
33 status: number;
34 body: string;
35 message: string;
36}> {}
37class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
38
39const request = (
40 operation: string,
41 params: Record<string, string> = {},
42 body?: unknown,
43) =>
44 Effect.gen(function* () {
45 const query = yield* Effect.sync(() =>
46 new URLSearchParams(params).toString(),
47 );
48 const endpoint = `${url}/${operation}?${query}`;
49 const req =
50 body === undefined
51 ? HttpClientRequest.get(endpoint)
52 : typeof body === "string"
53 ? HttpClientRequest.post(endpoint).pipe(
54 HttpClientRequest.bodyText(body),
55 )
56 : HttpClientRequest.post(endpoint).pipe(
57 HttpClientRequest.bodyJsonUnsafe(body),
58 );
59 return yield* HttpClient.execute(req).pipe(
60 Effect.flatMap((response) =>
61 response.status >= 500
62 ? response.text.pipe(
63 Effect.flatMap((body) =>
64 Effect.fail(
65 new NotReady({
66 status: response.status,
67 body,
68 message: `Lambda returned ${response.status}: ${body}`,
69 }),
70 ),
71 ),
72 )
73 : Effect.succeed(response),
74 ),
75 Effect.retry({
76 while: (error) => error._tag === "NotReady",
77 schedule: Schedule.spaced("4 seconds"),
78 times: 9,
79 }),
80 );
81 });
82const call = <T>(
83 operation: string,
84 params: Record<string, string> = {},
85 body?: unknown,
86) =>
87 request(operation, params, body).pipe(
88 Effect.flatMap((response) =>
89 Effect.gen(function* () {
90 const body = yield* response.json;
91 expect({ status: response.status, body }).toMatchObject({
92 status: 200,
93 });
94 return body as T;
95 }),
96 ),
97 );
98const seed = (operation: string, key = "versions.txt") =>
99 Effect.gen(function* () {
100 const Bucket = buckets[operation].bucketName;
101 const old = yield* S3.putObject({
102 Bucket,
103 Key: key,
104 Body: "old version",
105 Tagging: operation.endsWith("Tagging") ? "generation=old" : undefined,
106 });
107 const current = yield* S3.putObject({
108 Bucket,
109 Key: key,
110 Body: "the current version is longer",
111 Tagging: operation.endsWith("Tagging") ? "generation=current" : undefined,
112 });
113 expect(old.VersionId).toBeTruthy();
114 expect(current.VersionId).toBeTruthy();
115 expect(old.VersionId).not.toBe(current.VersionId);
116 return {
117 Bucket,
118 Key: key,
119 old: old.VersionId!,
120 current: current.VersionId!,
121 };
122 });
123const seedNull = (operation: string, Key: string) =>
124 Effect.gen(function* () {
125 const Bucket = buckets[operation].bucketName;
126 yield* Effect.gen(function* () {
127 yield* S3.putBucketVersioning({
128 Bucket,
129 VersioningConfiguration: { Status: "Suspended" },
130 });
131 yield* S3.putObject({
132 Bucket,
133 Key,
134 Body: "null version",
135 Tagging: operation.endsWith("Tagging") ? "generation=null" : undefined,
136 });
137 }).pipe(
138 Effect.ensuring(
139 S3.putBucketVersioning({
140 Bucket,
141 VersioningConfiguration: { Status: "Enabled" },
142 }).pipe(Effect.orDie),
143 ),
144 );
145 const current = yield* S3.putObject({
146 Bucket,
147 Key,
148 Body: "current after null",
149 Tagging: operation.endsWith("Tagging") ? "generation=current" : undefined,
150 });
151 expect(current.VersionId).toBeTruthy();
152 expect(current.VersionId).not.toBe("null");
153 const listed = yield* inventory(Bucket, Key);
154 expect(listed.versions.map((version) => version.VersionId).sort()).toEqual(
155 ["null", current.VersionId!].sort(),
156 );
157 expect(yield* read(Bucket, Key, "null")).toBe("null version");
158 return { Bucket, Key, current: current.VersionId! };
159 });
160const inventory = (Bucket: string, Key: string) =>
161 S3.listObjectVersions({ Bucket, Prefix: Key, MaxKeys: 1000 }).pipe(
162 Effect.map((page) => {
163 expect(page.IsTruncated).toBe(false);
164 return {
165 versions: (page.Versions ?? [])
166 .filter((version) => version.Key === Key)
167 .map(({ VersionId, ETag, Size, IsLatest }) => ({
168 VersionId,
169 ETag,
170 Size,
171 IsLatest,
172 }))
173 .sort((a, b) => a.VersionId!.localeCompare(b.VersionId!)),
174 markers: (page.DeleteMarkers ?? [])
175 .filter((marker) => marker.Key === Key)
176 .map(({ VersionId, IsLatest }) => ({ VersionId, IsLatest }))
177 .sort((a, b) => a.VersionId!.localeCompare(b.VersionId!)),
178 };
179 }),
180 );
181const read = (Bucket: string, Key: string, VersionId?: string) =>
182 S3.getObject({ Bucket, Key, VersionId }).pipe(
183 Effect.flatMap((result) =>
184 Stream.mkString(Stream.decodeText(result.Body!)),
185 ),
186 );
187const tags = (Bucket: string, Key: string, VersionId?: string) =>
188 S3.getObjectTagging({ Bucket, Key, VersionId }).pipe(
189 Effect.map((result) => result.TagSet ?? []),
190 );
191
192beforeAll(
193 Effect.gen(function* () {
194 yield* stack.destroy();
195 const fn = yield* stack.deploy(
196 VersionedObjectFunction.pipe(Effect.provide(VersionedObjectFunctionLive)),
197 );
198 url = fn.functionUrl!.replace(/\/+$/, "");
199 roleName = fn.roleName;
200 buckets = yield* request("info").pipe(
201 Effect.flatMap((response) => response.json),
202 Effect.flatMap(
203 Schema.decodeUnknownEffect(Schema.Record(Schema.String, BucketInfo)),
204 ),
205 );
206 }),
207 { timeout: 120_000 },
208);
209afterAll(
210 Effect.gen(function* () {
211 yield* stack.destroy();
212 yield* Core.withProviders(
213 Effect.forEach(
214 Object.values(buckets),
215 (bucket) =>
216 S3.headBucket({ Bucket: bucket.bucketName }).pipe(
217 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
218 Effect.catchTag("NotFound", () => Effect.void),
219 Effect.retry({
220 while: (error) => error._tag === "BucketStillExists",
221 schedule: Schedule.spaced("1 second"),
222 times: 9,
223 }),
224 ),
225 { concurrency: 4 },
226 ),
227 options,
228 "S3VersionedObjects",
229 );
230 }),
231 { timeout: 120_000 },
232);
233
234const policy = Schema.fromJsonString(
235 Schema.Struct({
236 Version: Schema.optional(Schema.String),
237 Statement: Schema.Array(
238 Schema.Struct({
239 Sid: Schema.optional(Schema.String),
240 Effect: Schema.Literal("Allow"),
241 Action: Schema.Array(Schema.String),
242 Resource: Schema.Array(Schema.String),
243 }),
244 ),
245 }),
246);
247
248describe.sequential(
249 "versioned S3 bindings",
250 { tags: ["provider:aws", "provider:aws:lambda", "provider:aws:s3", "live"] },
251 () => {
252 test.provider(
253 "isolates each binding's exact grants to its own bucket",
254 () =>
255 Effect.gen(function* () {
256 const attached = yield* IAM.listAttachedRolePolicies
257 .pages({ RoleName: roleName })
258 .pipe(Stream.runCollect);
259 expect(
260 attached
261 .flatMap((page) => page.AttachedPolicies ?? [])
262 .map((p) => p.PolicyArn),
263 ).toEqual([
264 "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
265 ]);
266 const pages = yield* IAM.listRolePolicies
267 .pages({ RoleName: roleName })
268 .pipe(Stream.runCollect);
269 const documents = yield* Effect.forEach(
270 pages.flatMap((page) => page.PolicyNames),
271 (PolicyName) =>
272 Effect.gen(function* () {
273 const result = yield* IAM.getRolePolicy({
274 RoleName: roleName,
275 PolicyName,
276 });
277 return yield* Schema.decodeUnknownEffect(policy, {
278 onExcessProperty: "error",
279 })(
280 yield* Effect.try(() =>
281 decodeURIComponent(result.PolicyDocument),
282 ),
283 );
284 }),
285 );
286 const actual = documents
287 .flatMap((doc) =>
288 doc.Statement.flatMap((s) =>
289 s.Action.flatMap((action) =>
290 s.Resource.map((resource) => `${action} ${resource}`),
291 ),
292 ),
293 )
294 .sort();
295 const grants: Record<
296 string,
297 { objects?: string[]; bucket?: string[] }
298 > = {
299 GetObject: {
300 objects: ["GetObject", "GetObjectVersion"],
301 bucket: ["ListBucket"],
302 },
303 PutObject: { objects: ["PutObject"] },
304 HeadObject: {
305 objects: ["GetObject", "GetObjectVersion"],
306 bucket: ["ListBucket"],
307 },
308 GetObjectAttributes: {
309 objects: [
310 "GetObject",
311 "GetObjectVersion",
312 "GetObjectAttributes",
313 "GetObjectVersionAttributes",
314 ],
315 bucket: ["ListBucket"],
316 },
317 CopyObject: {
318 objects: ["PutObject", "GetObject", "GetObjectVersion"],
319 },
320 CopySource: {
321 objects: ["GetObject", "GetObjectVersion"],
322 bucket: ["ListBucket"],
323 },
324 DeleteObject: { objects: ["DeleteObject", "DeleteObjectVersion"] },
325 DeleteObjects: { objects: ["DeleteObject", "DeleteObjectVersion"] },
326 ListObjectsV2: { bucket: ["ListBucket"] },
327 ListObjectVersions: { bucket: ["ListBucketVersions"] },
328 GetObjectTagging: {
329 objects: ["GetObjectTagging", "GetObjectVersionTagging"],
330 },
331 PutObjectTagging: {
332 objects: ["PutObjectTagging", "PutObjectVersionTagging"],
333 },
334 DeleteObjectTagging: {
335 objects: ["DeleteObjectTagging", "DeleteObjectVersionTagging"],
336 },
337 PresignPutObject: { objects: ["PutObject"] },
338 };
339 const expected = Object.entries(grants)
340 .flatMap(([operation, grants]) => [
341 ...(grants.objects ?? []).map(
342 (action) => `s3:${action} ${buckets[operation].bucketArn}/*`,
343 ),
344 ...(grants.bucket ?? []).map(
345 (action) => `s3:${action} ${buckets[operation].bucketArn}`,
346 ),
347 ])
348 .sort();
349 expect(actual).toEqual(expected);
350 }),
351 { tags: ["provider:aws:iam"] },
352 );
353
354 for (const operation of [
355 "GetObject",
356 "HeadObject",
357 "GetObjectAttributes",
358 "GetObjectTagging",
359 "PutObjectTagging",
360 "DeleteObjectTagging",
361 ] as const) {
362 describe(operation, () => {
363 test.provider(
364 "returns typed errors for a deleted version and a selected delete marker",
365 () =>
366 Effect.gen(function* () {
367 const v = yield* seed(operation, "version-errors.txt");
368 yield* S3.deleteObject({
369 Bucket: v.Bucket,
370 Key: v.Key,
371 VersionId: v.old,
372 });
373 const body =
374 operation === "PutObjectTagging"
375 ? [{ Key: "generation", Value: "rejected" }]
376 : undefined;
377 const remaining = yield* inventory(v.Bucket, v.Key);
378 const missing = yield* request(
379 operation,
380 {
381 key: v.Key,
382 versionId: v.old,
383 },
384 body,
385 );
386 expect(missing.status).toBe(
387 operation.endsWith("Tagging") ? 403 : 404,
388 );
389 expect(yield* missing.json).toEqual({
390 tag: operation.endsWith("Tagging")
391 ? "AccessDeniedException"
392 : operation === "HeadObject"
393 ? "NotFound"
394 : "NoSuchVersion",
395 });
396 expect(yield* inventory(v.Bucket, v.Key)).toEqual(remaining);
397 const marker = yield* S3.deleteObject({
398 Bucket: v.Bucket,
399 Key: v.Key,
400 });
401 expect(marker.VersionId).toBeTruthy();
402 const before = yield* inventory(v.Bucket, v.Key);
403 const selected = yield* request(
404 operation,
405 {
406 key: v.Key,
407 versionId: marker.VersionId!,
408 },
409 body,
410 );
411 expect(selected.status).toBe(
412 operation.endsWith("Tagging") ? 403 : 405,
413 );
414 if (operation === "HeadObject") {
415 const listed = yield* S3.listObjectVersions({
416 Bucket: v.Bucket,
417 Prefix: v.Key,
418 });
419 const observed = listed.DeleteMarkers?.find(
420 (entry) => entry.VersionId === marker.VersionId,
421 );
422 expect(observed?.LastModified).toBeDefined();
423 expect(yield* selected.json).toEqual({
424 tag: "MethodNotAllowed",
425 deleteMarker: true,
426 lastModified: observed!.LastModified!.toUTCString(),
427 });
428 } else {
429 expect(yield* selected.json).toEqual({
430 tag: operation.endsWith("Tagging")
431 ? "AccessDeniedException"
432 : "MethodNotAllowed",
433 });
434 }
435 expect(yield* inventory(v.Bucket, v.Key)).toEqual(before);
436 expect(yield* read(v.Bucket, v.Key, v.current)).toBe(
437 "the current version is longer",
438 );
439 if (operation.endsWith("Tagging")) {
440 expect(yield* tags(v.Bucket, v.Key, v.current)).toEqual([
441 { Key: "generation", Value: "current" },
442 ]);
443 }
444 }),
445 { timeout: 120_000, retry: 0 },
446 );
447 });
448 }
449
450 for (const operation of [
451 "GetObject",
452 "HeadObject",
453 "GetObjectAttributes",
454 "GetObjectTagging",
455 "PutObjectTagging",
456 "DeleteObjectTagging",
457 "DeleteObject",
458 "DeleteObjects",
459 "CopyObject",
460 ] as const) {
461 describe(operation, () => {
462 test.provider(
463 "selects the null version without changing the newer data version",
464 () =>
465 Effect.gen(function* () {
466 const v = yield* seedNull(operation, "null-selection.txt");
467 const before = yield* inventory(v.Bucket, v.Key);
468 const params = { key: v.Key, versionId: "null" };
469 switch (operation) {
470 case "GetObject":
471 expect(yield* call(operation, params)).toEqual({
472 body: "null version",
473 versionId: "null",
474 });
475 break;
476 case "HeadObject":
477 expect(yield* call(operation, params)).toEqual({
478 length: "null version".length,
479 versionId: "null",
480 });
481 break;
482 case "GetObjectAttributes":
483 expect(yield* call(operation, params)).toMatchObject({
484 ObjectSize: "null version".length,
485 VersionId: "null",
486 });
487 break;
488 case "GetObjectTagging":
489 expect(yield* call(operation, params)).toEqual({
490 TagSet: [{ Key: "generation", Value: "null" }],
491 VersionId: "null",
492 });
493 break;
494 case "PutObjectTagging":
495 yield* call(operation, params, [
496 { Key: "generation", Value: "updated-null" },
497 ]);
498 expect(yield* tags(v.Bucket, v.Key, "null")).toEqual([
499 { Key: "generation", Value: "updated-null" },
500 ]);
501 break;
502 case "DeleteObjectTagging":
503 yield* call(operation, params);
504 expect(yield* tags(v.Bucket, v.Key, "null")).toEqual([]);
505 break;
506 case "DeleteObject":
507 expect(yield* call(operation, params)).toMatchObject({
508 VersionId: "null",
509 });
510 break;
511 case "DeleteObjects": {
512 const deleted = yield* call<S3.DeleteObjectsOutput>(
513 operation,
514 {},
515 [{ Key: v.Key, VersionId: "null" }],
516 );
517 expect(deleted.Errors ?? []).toEqual([]);
518 expect(deleted.Deleted).toEqual([
519 { Key: v.Key, VersionId: "null" },
520 ]);
521 break;
522 }
523 case "CopyObject": {
524 const copied = yield* call<S3.CopyObjectOutput>(operation, {
525 key: "null-copy.txt",
526 source: `${v.Bucket}/${v.Key}?versionId=null`,
527 });
528 expect(copied.CopySourceVersionId).toBe("null");
529 expect(copied.VersionId).toBeTruthy();
530 expect(yield* read(v.Bucket, "null-copy.txt")).toBe(
531 "null version",
532 );
533 break;
534 }
535 }
536 const after = yield* inventory(v.Bucket, v.Key);
537 expect(after).toEqual(
538 operation === "DeleteObject" || operation === "DeleteObjects"
539 ? {
540 versions: before.versions.filter(
541 (version) => version.VersionId !== "null",
542 ),
543 markers: [],
544 }
545 : before,
546 );
547 expect(yield* read(v.Bucket, v.Key)).toBe("current after null");
548 expect(yield* tags(v.Bucket, v.Key, v.current)).toEqual(
549 operation.endsWith("Tagging")
550 ? [{ Key: "generation", Value: "current" }]
551 : [],
552 );
553 }),
554 { timeout: 120_000, retry: 0 },
555 );
556 });
557 }
558
559 describe("GetObject", () => {
560 test.provider(
561 "reads current and older contents, including behind a delete marker",
562 () =>
563 Effect.gen(function* () {
564 const v = yield* seed("GetObject");
565 expect(yield* call("GetObject", { key: v.Key })).toEqual({
566 body: "the current version is longer",
567 versionId: v.current,
568 });
569 expect(
570 yield* call("GetObject", { key: v.Key, versionId: v.old }),
571 ).toEqual({ body: "old version", versionId: v.old });
572 yield* S3.deleteObject({ Bucket: v.Bucket, Key: v.Key });
573 const missing = yield* request("GetObject", { key: v.Key });
574 expect(missing.status).toBe(404);
575 expect(yield* missing.json).toEqual({ tag: "NoSuchKey" });
576 expect(
577 yield* call("GetObject", { key: v.Key, versionId: v.old }),
578 ).toEqual({ body: "old version", versionId: v.old });
579 }),
580 );
581 });
582
583 describe("PutObject", () => {
584 test.provider(
585 "returns new version IDs on overwrite and preserves both contents",
586 () =>
587 Effect.gen(function* () {
588 const first = yield* call<S3.PutObjectOutput>(
589 "PutObject",
590 { key: "written.txt" },
591 "first write",
592 );
593 const second = yield* call<S3.PutObjectOutput>(
594 "PutObject",
595 { key: "written.txt" },
596 "second write",
597 );
598 expect(first.VersionId).toBeTruthy();
599 expect(second.VersionId).toBeTruthy();
600 expect(second.VersionId).not.toBe(first.VersionId);
601 const Bucket = buckets.PutObject.bucketName;
602 expect(yield* read(Bucket, "written.txt", first.VersionId)).toBe(
603 "first write",
604 );
605 expect(yield* read(Bucket, "written.txt")).toBe("second write");
606 }),
607 );
608 });
609
610 describe("HeadObject", () => {
611 test.provider(
612 "reports a current delete marker as missing while older metadata remains readable",
613 () =>
614 Effect.gen(function* () {
615 const v = yield* seed("HeadObject");
616 yield* S3.deleteObject({ Bucket: v.Bucket, Key: v.Key });
617 const missing = yield* request("HeadObject", { key: v.Key });
618 expect(missing.status).toBe(404);
619 expect(yield* missing.json).toEqual({ tag: "NotFound" });
620 expect(
621 yield* call("HeadObject", { key: v.Key, versionId: v.old }),
622 ).toEqual({ versionId: v.old, length: "old version".length });
623 }),
624 );
625 });
626
627 describe("GetObjectAttributes", () => {
628 test.provider(
629 "returns the selected version's size rather than the latest object's size",
630 () =>
631 Effect.gen(function* () {
632 const v = yield* seed("GetObjectAttributes");
633 const old = yield* call<S3.GetObjectAttributesOutput>(
634 "GetObjectAttributes",
635 { key: v.Key, versionId: v.old },
636 );
637 const current = yield* call<S3.GetObjectAttributesOutput>(
638 "GetObjectAttributes",
639 { key: v.Key },
640 );
641 expect(old.VersionId).toBe(v.old);
642 expect(old.ObjectSize).toBe("old version".length);
643 expect(current.VersionId).toBe(v.current);
644 expect(current.ObjectSize).toBe(
645 "the current version is longer".length,
646 );
647 }),
648 );
649 });
650
651 describe("CopyObject", () => {
652 test.provider(
653 "rejects a deleted source version and a selected source delete marker without creating destination versions",
654 () =>
655 Effect.gen(function* () {
656 const v = yield* seed("CopySource", "copy-errors.txt");
657 const Bucket = buckets.CopyObject.bucketName;
658 const Key = "rejected-copy.txt";
659 yield* S3.putObject({ Bucket, Key, Body: "unchanged destination" });
660 const before = yield* inventory(Bucket, Key);
661 yield* S3.deleteObject({
662 Bucket: v.Bucket,
663 Key: v.Key,
664 VersionId: v.old,
665 });
666 const missing = yield* request("CopyObject", {
667 key: Key,
668 source: yield* Effect.sync(
669 () =>
670 `${v.Bucket}/${v.Key}?versionId=${encodeURIComponent(v.old)}`,
671 ),
672 });
673 expect(missing.status).toBe(404);
674 expect(yield* missing.json).toEqual({ tag: "NoSuchVersion" });
675 const marker = yield* S3.deleteObject({
676 Bucket: v.Bucket,
677 Key: v.Key,
678 });
679 expect(marker.VersionId).toBeTruthy();
680 const selected = yield* request("CopyObject", {
681 key: Key,
682 source: yield* Effect.sync(
683 () =>
684 `${v.Bucket}/${v.Key}?versionId=${encodeURIComponent(marker.VersionId!)}`,
685 ),
686 });
687 expect(selected.status).toBe(400);
688 expect(yield* selected.json).toEqual({ tag: "InvalidRequest" });
689 const currentMarker = yield* request("CopyObject", {
690 key: Key,
691 source: `${v.Bucket}/${v.Key}`,
692 });
693 expect(currentMarker.status).toBe(404);
694 expect(yield* currentMarker.json).toEqual({ tag: "NoSuchKey" });
695 expect(yield* inventory(Bucket, Key)).toEqual(before);
696 expect(yield* read(Bucket, Key)).toBe("unchanged destination");
697 expect(yield* read(v.Bucket, v.Key, v.current)).toBe(
698 "the current version is longer",
699 );
700 }),
701 { timeout: 120_000, retry: 0 },
702 );
703 test.provider(
704 "copies an encoded older source version and creates a new destination version",
705 () =>
706 Effect.gen(function* () {
707 const v = yield* seed("CopyObject", "source/a + #?雪.txt");
708 const original = yield* S3.putObject({
709 Bucket: v.Bucket,
710 Key: "copy.txt",
711 Body: "original destination",
712 });
713 const source = yield* Effect.sync(
714 () =>
715 `${v.Bucket}/${v.Key.split("/").map(encodeURIComponent).join("/")}?versionId=${encodeURIComponent(v.old)}`,
716 );
717 const copied = yield* call<S3.CopyObjectOutput>("CopyObject", {
718 key: "copy.txt",
719 source,
720 });
721 expect(copied.CopySourceVersionId).toBe(v.old);
722 expect(copied.VersionId).toBeTruthy();
723 expect(copied.VersionId).not.toBe(original.VersionId);
724 expect(yield* read(v.Bucket, "copy.txt")).toBe("old version");
725 expect(yield* read(v.Bucket, "copy.txt", original.VersionId)).toBe(
726 "original destination",
727 );
728 const currentSource = yield* Effect.sync(
729 () =>
730 `${v.Bucket}/${v.Key.split("/").map(encodeURIComponent).join("/")}`,
731 );
732 yield* call("CopyObject", {
733 key: "current-copy.txt",
734 source: currentSource,
735 });
736 expect(yield* read(v.Bucket, "current-copy.txt")).toBe(
737 "the current version is longer",
738 );
739 }),
740 );
741 test.provider(
742 "copies versions from an explicitly bound source and denies an unrelated source",
743 () =>
744 Effect.gen(function* () {
745 const v = yield* seed("CopySource", "cross/source.txt");
746 const source = yield* Effect.sync(
747 () =>
748 `${v.Bucket}/${v.Key}?versionId=${encodeURIComponent(v.old)}`,
749 );
750 const copied = yield* call<S3.CopyObjectOutput>("CopyObject", {
751 key: "cross-destination.txt",
752 source,
753 });
754 expect(copied.CopySourceVersionId).toBe(v.old);
755 expect(copied.VersionId).toBeTruthy();
756 expect(
757 yield* read(
758 buckets.CopyObject.bucketName,
759 "cross-destination.txt",
760 ),
761 ).toBe("old version");
762 const unrelated = yield* seed("UnboundSource");
763 const deniedSource = yield* Effect.sync(
764 () =>
765 `${unrelated.Bucket}/${unrelated.Key}?versionId=${encodeURIComponent(unrelated.old)}`,
766 );
767 const denied = yield* request("CopyObject", {
768 key: "denied-copy.txt",
769 source: deniedSource,
770 });
771 expect(denied.status).toBe(403);
772 expect(yield* denied.json).toMatchObject({
773 tag: "AccessDeniedException",
774 });
775 const absent = yield* S3.headObject({
776 Bucket: buckets.CopyObject.bucketName,
777 Key: "denied-copy.txt",
778 }).pipe(
779 Effect.as(false),
780 Effect.catchTag("NotFound", () => Effect.succeed(true)),
781 );
782 expect(absent).toBe(true);
783 }),
784 );
785 });
786
787 describe("DeleteObject", () => {
788 test.provider(
789 "creates and removes delete markers and permanently deletes only the selected version",
790 () =>
791 Effect.gen(function* () {
792 const v = yield* seed("DeleteObject");
793 const marker = yield* call<S3.DeleteObjectOutput>("DeleteObject", {
794 key: v.Key,
795 });
796 expect(marker.DeleteMarker).toBe(true);
797 expect(marker.VersionId).toBeTruthy();
798 expect(yield* read(v.Bucket, v.Key, v.old)).toBe("old version");
799 const removed = yield* call<S3.DeleteObjectOutput>("DeleteObject", {
800 key: v.Key,
801 versionId: marker.VersionId!,
802 });
803 expect(removed.DeleteMarker).toBe(true);
804 expect(yield* read(v.Bucket, v.Key)).toBe(
805 "the current version is longer",
806 );
807 yield* call("DeleteObject", { key: v.Key, versionId: v.old });
808 const remaining = yield* S3.listObjectVersions({
809 Bucket: v.Bucket,
810 Prefix: v.Key,
811 });
812 expect(remaining.Versions?.map((x) => x.VersionId)).toEqual([
813 v.current,
814 ]);
815 expect(remaining.DeleteMarkers ?? []).toEqual([]);
816 }),
817 );
818 });
819
820 describe("DeleteObjects", () => {
821 test.provider(
822 "mixes explicit version deletion and delete-marker creation without deleting current data",
823 () =>
824 Effect.gen(function* () {
825 const v = yield* seed("DeleteObjects");
826 yield* S3.putObject({
827 Bucket: v.Bucket,
828 Key: "marker.txt",
829 Body: "still recoverable",
830 });
831 const result = yield* call<S3.DeleteObjectsOutput>(
832 "DeleteObjects",
833 {},
834 [{ Key: v.Key, VersionId: v.old }, { Key: "marker.txt" }],
835 );
836 expect(result.Errors ?? []).toEqual([]);
837 expect(
838 result.Deleted?.find((x) => x.Key === v.Key)?.VersionId,
839 ).toBe(v.old);
840 const marker = result.Deleted?.find((x) => x.Key === "marker.txt");
841 expect(marker?.DeleteMarker).toBe(true);
842 expect(marker?.DeleteMarkerVersionId).toBeTruthy();
843 expect(yield* read(v.Bucket, v.Key)).toBe(
844 "the current version is longer",
845 );
846 const remaining = yield* S3.listObjectVersions({
847 Bucket: v.Bucket,
848 Prefix: v.Key,
849 });
850 expect(remaining.Versions?.map((x) => x.VersionId)).toEqual([
851 v.current,
852 ]);
853 const restored = yield* call<S3.DeleteObjectsOutput>(
854 "DeleteObjects",
855 {},
856 [{ Key: "marker.txt", VersionId: marker!.DeleteMarkerVersionId }],
857 );
858 expect(restored.Errors ?? []).toEqual([]);
859 expect(yield* read(v.Bucket, "marker.txt")).toBe(
860 "still recoverable",
861 );
862 }),
863 );
864 });
865
866 describe("ListObjectsV2", () => {
867 test.provider(
868 "paginates current objects without exposing old versions or delete markers",
869 () =>
870 Effect.gen(function* () {
871 const v = yield* seed("ListObjectsV2", "list/a.txt");
872 yield* S3.putObject({
873 Bucket: v.Bucket,
874 Key: "list/b.txt",
875 Body: "visible",
876 });
877 yield* S3.putObject({
878 Bucket: v.Bucket,
879 Key: "list/hidden.txt",
880 Body: "hidden",
881 });
882 yield* S3.deleteObject({
883 Bucket: v.Bucket,
884 Key: "list/hidden.txt",
885 });
886 const first = yield* call<S3.ListObjectsV2Output>("ListObjectsV2", {
887 prefix: "list/",
888 });
889 expect(first.IsTruncated).toBe(true);
890 expect(first.Contents?.map((x) => x.Key)).toEqual(["list/a.txt"]);
891 expect(first.Contents?.[0].Size).toBe(
892 "the current version is longer".length,
893 );
894 const second = yield* call<S3.ListObjectsV2Output>(
895 "ListObjectsV2",
896 {
897 prefix: "list/",
898 token: first.NextContinuationToken!,
899 },
900 );
901 expect(second.Contents?.map((x) => x.Key)).toEqual(["list/b.txt"]);
902 expect(second.IsTruncated).toBe(false);
903 }),
904 );
905 });
906
907 describe("ListObjectVersions", () => {
908 test.provider(
909 "paginates versions and delete markers using both continuation markers",
910 () =>
911 Effect.gen(function* () {
912 const v = yield* seed("ListObjectVersions");
913 const marker = yield* S3.deleteObject({
914 Bucket: v.Bucket,
915 Key: v.Key,
916 });
917 const found: string[] = [];
918 const markers: string[] = [];
919 let params: Record<string, string> = { prefix: v.Key };
920 let complete = false;
921 for (let page = 0; page < 5; page++) {
922 const result = yield* call<S3.ListObjectVersionsOutput>(
923 "ListObjectVersions",
924 params,
925 );
926 found.push(...(result.Versions ?? []).map((x) => x.VersionId!));
927 markers.push(
928 ...(result.DeleteMarkers ?? []).map((x) => x.VersionId!),
929 );
930 if (!result.IsTruncated) {
931 complete = true;
932 break;
933 }
934 expect(result.NextKeyMarker).toBeTruthy();
935 expect(result.NextVersionIdMarker).toBeTruthy();
936 params = {
937 prefix: v.Key,
938 keyMarker: result.NextKeyMarker!,
939 versionMarker: result.NextVersionIdMarker!,
940 };
941 }
942 expect(complete).toBe(true);
943 expect(found.sort()).toEqual([v.old, v.current].sort());
944 expect(markers).toEqual([marker.VersionId!]);
945 }),
946 );
947 });
948
949 describe("GetObjectTagging", () => {
950 test.provider(
951 "reads independent tags for the current and selected old version",
952 () =>
953 Effect.gen(function* () {
954 const v = yield* seed("GetObjectTagging");
955 const old = yield* call<S3.GetObjectTaggingOutput>(
956 "GetObjectTagging",
957 { key: v.Key, versionId: v.old },
958 );
959 const current = yield* call<S3.GetObjectTaggingOutput>(
960 "GetObjectTagging",
961 { key: v.Key },
962 );
963 expect(old.VersionId).toBe(v.old);
964 expect(old.TagSet).toEqual([{ Key: "generation", Value: "old" }]);
965 expect(current.VersionId).toBe(v.current);
966 expect(current.TagSet).toEqual([
967 { Key: "generation", Value: "current" },
968 ]);
969 }),
970 );
971 });
972
973 describe("PutObjectTagging", () => {
974 test.provider(
975 "updates the selected old version without mutating current tags",
976 () =>
977 Effect.gen(function* () {
978 const v = yield* seed("PutObjectTagging");
979 const before = yield* inventory(v.Bucket, v.Key);
980 const updated = [{ Key: "generation", Value: "updated-old" }];
981 yield* call(
982 "PutObjectTagging",
983 { key: v.Key, versionId: v.old },
984 updated,
985 );
986 expect(yield* tags(v.Bucket, v.Key, v.old)).toEqual(updated);
987 expect(yield* tags(v.Bucket, v.Key)).toEqual([
988 { Key: "generation", Value: "current" },
989 ]);
990 yield* call("PutObjectTagging", { key: v.Key }, [
991 { Key: "generation", Value: "updated-current" },
992 ]);
993 expect(yield* tags(v.Bucket, v.Key, v.old)).toEqual(updated);
994 expect(yield* tags(v.Bucket, v.Key)).toEqual([
995 { Key: "generation", Value: "updated-current" },
996 ]);
997 expect(yield* inventory(v.Bucket, v.Key)).toEqual(before);
998 yield* call(
999 "PutObjectTagging",
1000 { key: v.Key, versionId: v.old },
1001 updated,
1002 );
1003 expect(yield* inventory(v.Bucket, v.Key)).toEqual(before);
1004 }),
1005 );
1006 });
1007
1008 describe("DeleteObjectTagging", () => {
1009 test.provider("removes only the selected version's tags", () =>
1010 Effect.gen(function* () {
1011 const v = yield* seed("DeleteObjectTagging");
1012 const before = yield* inventory(v.Bucket, v.Key);
1013 yield* call("DeleteObjectTagging", { key: v.Key, versionId: v.old });
1014 expect(yield* tags(v.Bucket, v.Key, v.old)).toEqual([]);
1015 expect(yield* tags(v.Bucket, v.Key)).toEqual([
1016 { Key: "generation", Value: "current" },
1017 ]);
1018 yield* call("DeleteObjectTagging", { key: v.Key });
1019 expect(yield* tags(v.Bucket, v.Key)).toEqual([]);
1020 expect(yield* inventory(v.Bucket, v.Key)).toEqual(before);
1021 yield* call("DeleteObjectTagging", { key: v.Key, versionId: v.old });
1022 expect(yield* inventory(v.Bucket, v.Key)).toEqual(before);
1023 }),
1024 );
1025 });
1026
1027 describe("PresignPutObject", () => {
1028 test.provider(
1029 "creates a new version without overwriting the previous contents",
1030 () =>
1031 Effect.gen(function* () {
1032 const v = yield* seed("PresignPutObject");
1033 const signed = yield* call<{ url: string }>("PresignPutObject", {
1034 key: v.Key,
1035 });
1036 const result = yield* HttpClient.execute(
1037 HttpClientRequest.put(signed.url).pipe(
1038 HttpClientRequest.bodyText("presigned version", "text/plain"),
1039 ),
1040 );
1041 expect(result.status).toBe(200);
1042 const version = result.headers["x-amz-version-id"];
1043 expect(version).toBeTruthy();
1044 expect(version).not.toBe(v.current);
1045 expect(yield* read(v.Bucket, v.Key)).toBe("presigned version");
1046 expect(yield* read(v.Bucket, v.Key, v.current)).toBe(
1047 "the current version is longer",
1048 );
1049 expect(yield* read(v.Bucket, v.Key, v.old)).toBe("old version");
1050 }),
1051 );
1052 });
1053 },
1054);