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 Lambda from "@distilled.cloud/aws/lambda";
6import * as S3 from "@distilled.cloud/aws/s3";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
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 S3VersionedObjectLockFunctionLive, {
16 S3VersionedObjectLockFunction,
17} from "./fixtures/versioned-object-lock-handler.ts";
18
19const options = { providers: AWS.providers(), dev: false };
20const { test, beforeAll, afterAll } = Test.make(options);
21const stack = Core.scratchStack(
22 options,
23 "S3VersionedObjectLock",
24 "test/AWS/S3/VersionedObjectLock.test.ts",
25);
26const actions = {
27 GetObjectRetention: "s3:GetObjectRetention",
28 PutObjectRetention: "s3:PutObjectRetention",
29 GetObjectLegalHold: "s3:GetObjectLegalHold",
30 PutObjectLegalHold: "s3:PutObjectLegalHold",
31 RestoreObject: "s3:RestoreObject",
32} as const;
33type Binding = keyof typeof actions;
34const bucketInfo = Schema.Array(
35 Schema.Struct({
36 binding: Schema.Literals([
37 "GetObjectRetention",
38 "PutObjectRetention",
39 "GetObjectLegalHold",
40 "PutObjectLegalHold",
41 "RestoreObject",
42 ]),
43 bucketName: Schema.String,
44 bucketArn: Schema.String,
45 }),
46);
47let buckets: typeof bucketInfo.Type = [];
48let baseUrl: string;
49let roleName: string;
50let functionName: string;
51
52class FixtureNotReady extends Data.TaggedError("FixtureNotReady") {}
53class UnexpectedFixtureStatus extends Data.TaggedError(
54 "UnexpectedFixtureStatus",
55)<{
56 readonly status: number;
57}> {}
58class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
59
60const bucketFor = (binding: Binding) => {
61 const bucket = buckets.find((bucket) => bucket.binding === binding);
62 if (!bucket) throw new Error(`Missing fixture bucket for ${binding}`);
63 return bucket.bucketName;
64};
65
66const post = <A, I>(path: string, body: object, schema: Schema.Codec<A, I>) =>
67 Effect.gen(function* () {
68 const response = yield* HttpClient.execute(
69 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
70 HttpClientRequest.bodyJsonUnsafe(body),
71 ),
72 );
73 if (response.status !== 200) {
74 return yield* Effect.fail(
75 new Error(`${path}: HTTP ${response.status}: ${yield* response.text}`),
76 );
77 }
78 return yield* Schema.decodeUnknownEffect(schema)(yield* response.json);
79 });
80
81const rejected = (path: string, body: object, status: number, tag: string) =>
82 Effect.gen(function* () {
83 const response = yield* HttpClient.execute(
84 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
85 HttpClientRequest.bodyJsonUnsafe(body),
86 ),
87 );
88 const result = yield* response.json;
89 expect({ status: response.status, body: result }).toEqual({
90 status,
91 body: { tag },
92 });
93 });
94
95const assertProtectedDelete = (
96 Bucket: string,
97 Key: string,
98 VersionId: string,
99) =>
100 S3.deleteObject({ Bucket, Key, VersionId }).pipe(
101 Effect.as("deleted"),
102 Effect.catchTag("AccessDeniedException", () =>
103 Effect.succeed("AccessDeniedException"),
104 ),
105 Effect.tap((tag) =>
106 Effect.sync(() => expect(tag).toBe("AccessDeniedException")),
107 ),
108 );
109
110const policyDocument = Schema.fromJsonString(
111 Schema.Struct({
112 Statement: Schema.Array(
113 Schema.Struct({
114 Effect: Schema.String,
115 Action: Schema.Union([Schema.String, Schema.Array(Schema.String)]),
116 Resource: Schema.Union([Schema.String, Schema.Array(Schema.String)]),
117 }),
118 ),
119 }),
120);
121
122const assertPermissions = Effect.gen(function* () {
123 const configuration = yield* Lambda.getFunctionConfiguration({
124 FunctionName: functionName,
125 });
126 expect(configuration.FunctionName).toBe(functionName);
127 expect(configuration.Role?.endsWith(`/${roleName}`)).toBe(true);
128 const attached = yield* IAM.listAttachedRolePolicies
129 .pages({ RoleName: roleName })
130 .pipe(Stream.runCollect);
131 expect(
132 attached
133 .flatMap((page) => page.AttachedPolicies ?? [])
134 .map((policy) => policy.PolicyArn),
135 ).toEqual([
136 "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
137 ]);
138 const pages = yield* IAM.listRolePolicies
139 .pages({ RoleName: roleName })
140 .pipe(Stream.runCollect);
141 const policies = yield* Effect.forEach(
142 pages.flatMap((page) => page.PolicyNames),
143 (PolicyName) =>
144 Effect.gen(function* () {
145 const policy = yield* IAM.getRolePolicy({
146 RoleName: roleName,
147 PolicyName,
148 });
149 const decoded = yield* Effect.try(() =>
150 decodeURIComponent(policy.PolicyDocument),
151 );
152 return yield* Schema.decodeUnknownEffect(policyDocument)(decoded);
153 }),
154 );
155 const actual = policies
156 .flatMap((policy) =>
157 policy.Statement.flatMap((statement) => {
158 const actionList =
159 typeof statement.Action === "string"
160 ? [statement.Action]
161 : statement.Action;
162 const resources =
163 typeof statement.Resource === "string"
164 ? [statement.Resource]
165 : statement.Resource;
166 return actionList.flatMap((action) =>
167 resources.map(
168 (resource) => `${statement.Effect} ${action} ${resource}`,
169 ),
170 );
171 }),
172 )
173 .sort();
174 expect(actual).toEqual(
175 buckets
176 .map((bucket) => `Allow ${actions[bucket.binding]} ${bucket.bucketArn}/*`)
177 .sort(),
178 );
179 for (const bucket of buckets) {
180 expect(
181 (yield* S3.getBucketVersioning({ Bucket: bucket.bucketName })).Status,
182 ).toBe("Enabled");
183 if (bucket.binding !== "RestoreObject") {
184 const lock = yield* S3.getObjectLockConfiguration({
185 Bucket: bucket.bucketName,
186 });
187 expect(lock.ObjectLockConfiguration?.ObjectLockEnabled).toBe("Enabled");
188 expect(lock.ObjectLockConfiguration?.Rule).toBeUndefined();
189 }
190 }
191});
192
193const assertBucketDeleted = (Bucket: string) =>
194 S3.headBucket({ Bucket }).pipe(
195 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
196 Effect.retry({
197 while: (error) => error._tag === "BucketStillExists",
198 schedule: Schedule.spaced("1 second"),
199 times: 9,
200 }),
201 Effect.catchTag("NotFound", () => Effect.void),
202 );
203
204const seedVersions = Effect.fn(function* (
205 Bucket: string,
206 Key: string,
207 oldStorageClass: "STANDARD" | "GLACIER" = "STANDARD",
208) {
209 const oldBody = "original object-lock version";
210 const currentBody = "latest object-lock version has different content";
211 const old = yield* S3.putObject({
212 Bucket,
213 Key,
214 Body: oldBody,
215 StorageClass: oldStorageClass,
216 });
217 const current = yield* S3.putObject({
218 Bucket,
219 Key,
220 Body: currentBody,
221 StorageClass: "STANDARD",
222 });
223 expect(old.VersionId).toBeTruthy();
224 expect(current.VersionId).toBeTruthy();
225 expect(old.VersionId).not.toBe("null");
226 expect(current.VersionId).not.toBe(old.VersionId);
227 return {
228 old: old.VersionId!,
229 current: current.VersionId!,
230 oldBody,
231 currentBody,
232 };
233});
234
235const assertVersions = Effect.fn(function* (
236 Bucket: string,
237 Key: string,
238 versions: {
239 old: string;
240 current: string;
241 oldBody: string;
242 currentBody: string;
243 },
244) {
245 for (const [VersionId, expectedId, body] of [
246 [undefined, versions.current, versions.currentBody],
247 [versions.old, versions.old, versions.oldBody],
248 ] as const) {
249 const object = yield* S3.getObject({ Bucket, Key, VersionId });
250 expect(object.VersionId).toBe(expectedId);
251 expect(object.Body).toBeDefined();
252 expect(yield* object.Body!.pipe(Stream.decodeText, Stream.mkString)).toBe(
253 body,
254 );
255 }
256 const listed = yield* S3.listObjectVersions({ Bucket, Prefix: Key });
257 expect(
258 listed.Versions?.filter((version) => version.Key === Key)
259 .map((version) => ({
260 id: version.VersionId,
261 latest: version.IsLatest,
262 }))
263 .sort((a, b) => a.id!.localeCompare(b.id!)),
264 ).toEqual(
265 [
266 { id: versions.old, latest: false },
267 { id: versions.current, latest: true },
268 ].sort((a, b) => a.id.localeCompare(b.id)),
269 );
270});
271
272const retentionDates = Effect.sync(() => {
273 const now = Math.floor(Date.now() / 1000) * 1000;
274 return {
275 old: new Date(now + 86_400_000),
276 current: new Date(now + 2 * 86_400_000),
277 extended: new Date(now + 3 * 86_400_000),
278 };
279});
280
281const removeRetention = (Bucket: string, Key: string, versionIds: string[]) =>
282 Effect.forEach(
283 versionIds,
284 (VersionId) =>
285 S3.putObjectRetention({
286 Bucket,
287 Key,
288 VersionId,
289 Retention: {},
290 BypassGovernanceRetention: true,
291 }),
292 { discard: true },
293 ).pipe(Effect.orDie);
294
295const removeHolds = (Bucket: string, Key: string, versionIds: string[]) =>
296 Effect.forEach(
297 versionIds,
298 (VersionId) =>
299 S3.putObjectLegalHold({
300 Bucket,
301 Key,
302 VersionId,
303 LegalHold: { Status: "OFF" },
304 }),
305 { discard: true },
306 ).pipe(Effect.orDie);
307
308const retentionResponse = Schema.Struct({
309 Retention: Schema.Struct({
310 Mode: Schema.Literal("GOVERNANCE"),
311 RetainUntilDate: Schema.String,
312 }),
313});
314const holdResponse = Schema.Struct({
315 LegalHold: Schema.Struct({ Status: Schema.Literals(["ON", "OFF"]) }),
316});
317
318beforeAll(
319 Effect.gen(function* () {
320 yield* stack.destroy();
321 const deployed = yield* stack.deploy(
322 S3VersionedObjectLockFunction.pipe(
323 Effect.provide(S3VersionedObjectLockFunctionLive),
324 ),
325 );
326 expect(deployed.functionUrl).toBeTruthy();
327 baseUrl = deployed.functionUrl!.replace(/\/+$/, "");
328 roleName = deployed.roleName;
329 functionName = deployed.functionName;
330 buckets = yield* HttpClient.get(`${baseUrl}/info`).pipe(
331 Effect.flatMap((response) =>
332 Effect.gen(function* () {
333 if (response.status === 503 || response.status === 502) {
334 return yield* Effect.fail(new FixtureNotReady());
335 }
336 if (response.status === 200) return yield* response.json;
337 return yield* Effect.fail(
338 new UnexpectedFixtureStatus({ status: response.status }),
339 );
340 }),
341 ),
342 Effect.flatMap(Schema.decodeUnknownEffect(bucketInfo)),
343 Effect.retry({
344 while: (error) =>
345 error._tag === "FixtureNotReady" ||
346 (error._tag === "HttpClientError" &&
347 error.reason._tag === "TransportError"),
348 schedule: Schedule.spaced("2 seconds"),
349 times: 9,
350 }),
351 );
352 expect(buckets.map((bucket) => bucket.binding).sort()).toEqual(
353 Object.keys(actions).sort(),
354 );
355 expect(new Set(buckets.map((bucket) => bucket.bucketName)).size).toBe(5);
356 yield* Core.withProviders(
357 assertPermissions,
358 options,
359 "S3VersionedObjectLock",
360 );
361 }),
362 { timeout: 120_000, retry: 0 },
363);
364
365afterAll(
366 Effect.gen(function* () {
367 yield* stack.destroy();
368 yield* Core.withProviders(
369 Effect.forEach(
370 buckets,
371 (bucket) => assertBucketDeleted(bucket.bucketName),
372 { discard: true },
373 ),
374 options,
375 "S3VersionedObjectLock",
376 );
377 }),
378 { timeout: 120_000, retry: 0 },
379);
380
381describe.sequential(
382 "versioned object lock bindings",
383 {
384 tags: [
385 "provider:aws",
386 "provider:aws:iam",
387 "provider:aws:lambda",
388 "provider:aws:s3",
389 "live",
390 ],
391 },
392 () => {
393 for (const [binding, path] of [
394 ["GetObjectRetention", "/get-retention"],
395 ["PutObjectRetention", "/put-retention"],
396 ["GetObjectLegalHold", "/get-hold"],
397 ["PutObjectLegalHold", "/put-hold"],
398 ["RestoreObject", "/restore"],
399 ] as const) {
400 describe(binding, () => {
401 test.provider(
402 "returns typed errors for missing versions and a selected delete marker",
403 () =>
404 Effect.gen(function* () {
405 const Bucket = bucketFor(binding);
406 const Key = "versioned-object-lock/version-errors.txt";
407 const versions = yield* seedVersions(Bucket, Key);
408 const dates = yield* retentionDates;
409 const input = {
410 Key,
411 retainUntil: dates.old.toISOString(),
412 Status: "OFF",
413 };
414 yield* Effect.gen(function* () {
415 yield* S3.deleteObject({
416 Bucket,
417 Key,
418 VersionId: versions.old,
419 });
420 for (const VersionId of [versions.old, "null"]) {
421 yield* rejected(
422 path,
423 { ...input, VersionId },
424 403,
425 "AccessDeniedException",
426 );
427 }
428 const marker = yield* S3.deleteObject({ Bucket, Key });
429 expect(marker.VersionId).toBeTruthy();
430 yield* rejected(
431 path,
432 { ...input, VersionId: marker.VersionId! },
433 binding === "RestoreObject" ? 405 : 403,
434 binding === "RestoreObject"
435 ? "MethodNotAllowed"
436 : "AccessDeniedException",
437 );
438 const current = yield* S3.getObject({
439 Bucket,
440 Key,
441 VersionId: versions.current,
442 });
443 expect(current.VersionId).toBe(versions.current);
444 expect(
445 yield* current.Body!.pipe(Stream.decodeText, Stream.mkString),
446 ).toBe(versions.currentBody);
447 const listed = yield* S3.listObjectVersions({
448 Bucket,
449 Prefix: Key,
450 });
451 expect(
452 listed.Versions?.map((version) => version.VersionId),
453 ).toEqual([versions.current]);
454 expect(
455 listed.DeleteMarkers?.map((version) => version.VersionId),
456 ).toEqual([marker.VersionId]);
457 }).pipe(
458 Effect.ensuring(
459 binding === "PutObjectRetention"
460 ? removeRetention(Bucket, Key, [versions.current])
461 : Effect.void,
462 ),
463 );
464 }),
465 { timeout: 120_000, retry: 0 },
466 );
467 });
468 }
469
470 describe("GetObjectRetention", () => {
471 test.provider(
472 "reads the selected old version's retention separately from the latest version",
473 () =>
474 Effect.gen(function* () {
475 const Bucket = bucketFor("GetObjectRetention");
476 const Key = "versioned-object-lock/get-retention.txt";
477 const versions = yield* seedVersions(Bucket, Key);
478 const dates = yield* retentionDates;
479 yield* Effect.gen(function* () {
480 yield* S3.putObjectRetention({
481 Bucket,
482 Key,
483 VersionId: versions.old,
484 Retention: { Mode: "GOVERNANCE", RetainUntilDate: dates.old },
485 });
486 yield* S3.putObjectRetention({
487 Bucket,
488 Key,
489 VersionId: versions.current,
490 Retention: {
491 Mode: "GOVERNANCE",
492 RetainUntilDate: dates.current,
493 },
494 });
495 yield* assertProtectedDelete(Bucket, Key, versions.old);
496 const old = yield* post(
497 "/get-retention",
498 { Key, VersionId: versions.old },
499 retentionResponse,
500 );
501 const current = yield* post(
502 "/get-retention",
503 { Key },
504 retentionResponse,
505 );
506 expect(old.Retention).toEqual({
507 Mode: "GOVERNANCE",
508 RetainUntilDate: dates.old.toISOString(),
509 });
510 expect(current.Retention).toEqual({
511 Mode: "GOVERNANCE",
512 RetainUntilDate: dates.current.toISOString(),
513 });
514 expect(old.Retention).not.toEqual(current.Retention);
515 expect(
516 yield* post(
517 "/get-retention",
518 { Key, VersionId: versions.current },
519 retentionResponse,
520 ),
521 ).toEqual(current);
522 expect(
523 (yield* S3.getObjectRetention({
524 Bucket,
525 Key,
526 VersionId: versions.old,
527 })).Retention,
528 ).toEqual({ Mode: "GOVERNANCE", RetainUntilDate: dates.old });
529 expect(
530 (yield* S3.getObjectRetention({ Bucket, Key })).Retention,
531 ).toEqual({ Mode: "GOVERNANCE", RetainUntilDate: dates.current });
532 yield* assertVersions(Bucket, Key, versions);
533 }).pipe(
534 Effect.ensuring(
535 removeRetention(Bucket, Key, [versions.old, versions.current]),
536 ),
537 );
538 }),
539 { timeout: 120_000, retry: 0 },
540 );
541 });
542
543 describe("PutObjectRetention", () => {
544 test.provider(
545 "extends GOVERNANCE retention on the old version without modifying latest retention",
546 () =>
547 Effect.gen(function* () {
548 const Bucket = bucketFor("PutObjectRetention");
549 const Key = "versioned-object-lock/put-retention.txt";
550 const versions = yield* seedVersions(Bucket, Key);
551 const dates = yield* retentionDates;
552 yield* Effect.gen(function* () {
553 yield* S3.putObjectRetention({
554 Bucket,
555 Key,
556 VersionId: versions.old,
557 Retention: { Mode: "GOVERNANCE", RetainUntilDate: dates.old },
558 });
559 yield* S3.putObjectRetention({
560 Bucket,
561 Key,
562 VersionId: versions.current,
563 Retention: {
564 Mode: "GOVERNANCE",
565 RetainUntilDate: dates.current,
566 },
567 });
568 expect(
569 yield* post(
570 "/put-retention",
571 {
572 Key,
573 VersionId: versions.old,
574 retainUntil: dates.extended.toISOString(),
575 },
576 Schema.Struct({ retained: Schema.Boolean }),
577 ),
578 ).toEqual({ retained: true });
579 yield* rejected(
580 "/put-retention",
581 {
582 Key,
583 VersionId: versions.old,
584 retainUntil: dates.old.toISOString(),
585 },
586 403,
587 "AccessDeniedException",
588 );
589 yield* assertProtectedDelete(Bucket, Key, versions.old);
590 expect(
591 (yield* S3.getObjectRetention({
592 Bucket,
593 Key,
594 VersionId: versions.old,
595 })).Retention,
596 ).toEqual({
597 Mode: "GOVERNANCE",
598 RetainUntilDate: dates.extended,
599 });
600 expect(
601 (yield* S3.getObjectRetention({ Bucket, Key })).Retention,
602 ).toEqual({ Mode: "GOVERNANCE", RetainUntilDate: dates.current });
603 expect(
604 (yield* S3.getObjectRetention({
605 Bucket,
606 Key,
607 VersionId: versions.current,
608 })).Retention,
609 ).toEqual({ Mode: "GOVERNANCE", RetainUntilDate: dates.current });
610 yield* assertVersions(Bucket, Key, versions);
611 }).pipe(
612 Effect.ensuring(
613 removeRetention(Bucket, Key, [versions.old, versions.current]),
614 ),
615 );
616 }),
617 { timeout: 120_000, retry: 0 },
618 );
619 });
620
621 describe("GetObjectLegalHold", () => {
622 test.provider(
623 "reads ON for the selected old version and OFF for the current version",
624 () =>
625 Effect.gen(function* () {
626 const Bucket = bucketFor("GetObjectLegalHold");
627 const Key = "versioned-object-lock/get-hold.txt";
628 const versions = yield* seedVersions(Bucket, Key);
629 yield* Effect.gen(function* () {
630 yield* S3.putObjectLegalHold({
631 Bucket,
632 Key,
633 VersionId: versions.old,
634 LegalHold: { Status: "ON" },
635 });
636 yield* S3.putObjectLegalHold({
637 Bucket,
638 Key,
639 VersionId: versions.current,
640 LegalHold: { Status: "OFF" },
641 });
642 expect(
643 yield* post(
644 "/get-hold",
645 { Key, VersionId: versions.old },
646 holdResponse,
647 ),
648 ).toEqual({ LegalHold: { Status: "ON" } });
649 yield* assertProtectedDelete(Bucket, Key, versions.old);
650 expect(yield* post("/get-hold", { Key }, holdResponse)).toEqual({
651 LegalHold: { Status: "OFF" },
652 });
653 expect(
654 yield* post(
655 "/get-hold",
656 { Key, VersionId: versions.current },
657 holdResponse,
658 ),
659 ).toEqual({ LegalHold: { Status: "OFF" } });
660 expect(
661 (yield* S3.getObjectLegalHold({
662 Bucket,
663 Key,
664 VersionId: versions.old,
665 })).LegalHold?.Status,
666 ).toBe("ON");
667 expect(
668 (yield* S3.getObjectLegalHold({ Bucket, Key })).LegalHold
669 ?.Status,
670 ).toBe("OFF");
671 yield* assertVersions(Bucket, Key, versions);
672 }).pipe(
673 Effect.ensuring(
674 removeHolds(Bucket, Key, [versions.old, versions.current]),
675 ),
676 );
677 }),
678 { timeout: 120_000, retry: 0 },
679 );
680 });
681
682 describe("PutObjectLegalHold", () => {
683 test.provider(
684 "toggles only the selected old version's legal hold while latest stays OFF",
685 () =>
686 Effect.gen(function* () {
687 const Bucket = bucketFor("PutObjectLegalHold");
688 const Key = "versioned-object-lock/put-hold.txt";
689 const versions = yield* seedVersions(Bucket, Key);
690 yield* Effect.gen(function* () {
691 yield* S3.putObjectLegalHold({
692 Bucket,
693 Key,
694 VersionId: versions.old,
695 LegalHold: { Status: "OFF" },
696 });
697 yield* S3.putObjectLegalHold({
698 Bucket,
699 Key,
700 VersionId: versions.current,
701 LegalHold: { Status: "OFF" },
702 });
703 for (const Status of ["ON", "OFF"] as const) {
704 expect(
705 yield* post(
706 "/put-hold",
707 { Key, VersionId: versions.old, Status },
708 Schema.Struct({ updated: Schema.Boolean }),
709 ),
710 ).toEqual({ updated: true });
711 if (Status === "ON") {
712 yield* assertProtectedDelete(Bucket, Key, versions.old);
713 }
714 expect(
715 (yield* S3.getObjectLegalHold({
716 Bucket,
717 Key,
718 VersionId: versions.old,
719 })).LegalHold?.Status,
720 ).toBe(Status);
721 expect(
722 (yield* S3.getObjectLegalHold({ Bucket, Key })).LegalHold
723 ?.Status,
724 ).toBe("OFF");
725 expect(
726 (yield* S3.getObjectLegalHold({
727 Bucket,
728 Key,
729 VersionId: versions.current,
730 })).LegalHold?.Status,
731 ).toBe("OFF");
732 }
733 yield* assertVersions(Bucket, Key, versions);
734 }).pipe(
735 Effect.ensuring(
736 removeHolds(Bucket, Key, [versions.old, versions.current]),
737 ),
738 );
739 }),
740 { timeout: 120_000, retry: 0 },
741 );
742 });
743
744 describe("RestoreObject", () => {
745 test.provider(
746 "selects a suspended null STANDARD version without changing current data",
747 () =>
748 Effect.gen(function* () {
749 const Bucket = bucketFor("RestoreObject");
750 const Key = "versioned-object-lock/restore-null.txt";
751 yield* Effect.gen(function* () {
752 yield* S3.putBucketVersioning({
753 Bucket,
754 VersioningConfiguration: { Status: "Suspended" },
755 });
756 yield* S3.putObject({
757 Bucket,
758 Key,
759 Body: "null restore version",
760 });
761 }).pipe(
762 Effect.ensuring(
763 S3.putBucketVersioning({
764 Bucket,
765 VersioningConfiguration: { Status: "Enabled" },
766 }).pipe(Effect.orDie),
767 ),
768 );
769 const current = yield* S3.putObject({
770 Bucket,
771 Key,
772 Body: "current restore version",
773 });
774 expect(current.VersionId).toBeTruthy();
775 expect(current.VersionId).not.toBe("null");
776 expect(
777 yield* post(
778 "/restore",
779 { Key, VersionId: "null" },
780 Schema.Struct({ tag: Schema.Literal("InvalidObjectState") }),
781 ),
782 ).toEqual({ tag: "InvalidObjectState" });
783 for (const [VersionId, expectedBody] of [
784 ["null", "null restore version"],
785 [current.VersionId!, "current restore version"],
786 ]) {
787 const head = yield* S3.headObject({ Bucket, Key, VersionId });
788 expect(head.VersionId).toBe(VersionId);
789 expect(head.Restore).toBeUndefined();
790 const object = yield* S3.getObject({ Bucket, Key, VersionId });
791 expect(
792 yield* object.Body!.pipe(Stream.decodeText, Stream.mkString),
793 ).toBe(expectedBody);
794 }
795 const listed = yield* S3.listObjectVersions({
796 Bucket,
797 Prefix: Key,
798 });
799 expect(
800 listed.Versions?.map((version) => ({
801 id: version.VersionId,
802 latest: version.IsLatest,
803 })),
804 ).toEqual([
805 { id: current.VersionId, latest: true },
806 { id: "null", latest: false },
807 ]);
808 expect(listed.DeleteMarkers ?? []).toEqual([]);
809 }),
810 { timeout: 120_000, retry: 0 },
811 );
812 test.provider(
813 "returns typed InvalidObjectState for a selected STANDARD version without restoring or changing latest",
814 () =>
815 Effect.gen(function* () {
816 const Bucket = bucketFor("RestoreObject");
817 const Key = "versioned-object-lock/restore-standard.txt";
818 const versions = yield* seedVersions(Bucket, Key);
819 const response = Schema.Struct({
820 tag: Schema.Literal("InvalidObjectState"),
821 });
822 expect(
823 yield* post(
824 "/restore",
825 { Key, VersionId: versions.old },
826 response,
827 ),
828 ).toEqual({ tag: "InvalidObjectState" });
829 expect(yield* post("/restore", { Key }, response)).toEqual({
830 tag: "InvalidObjectState",
831 });
832 for (const VersionId of [versions.old, versions.current]) {
833 const head = yield* S3.headObject({ Bucket, Key, VersionId });
834 expect(head.VersionId).toBe(VersionId);
835 expect(head.StorageClass ?? "STANDARD").toBe("STANDARD");
836 expect(head.Restore).toBeUndefined();
837 }
838 yield* assertVersions(Bucket, Key, versions);
839 }),
840 { timeout: 120_000, retry: 0 },
841 );
842
843 test.provider(
844 "accepts restoration of an old GLACIER version without waiting for retrieval or changing latest STANDARD",
845 () =>
846 Effect.gen(function* () {
847 const Bucket = bucketFor("RestoreObject");
848 const Key = "versioned-object-lock/restore-glacier.txt";
849 const versions = yield* seedVersions(Bucket, Key, "GLACIER");
850 const archived = yield* S3.headObject({
851 Bucket,
852 Key,
853 VersionId: versions.old,
854 });
855 expect(archived.VersionId).toBe(versions.old);
856 expect(archived.StorageClass).toBe("GLACIER");
857 expect(archived.ContentLength).toBe(versions.oldBody.length);
858 expect(archived.Restore).toBeUndefined();
859 expect(
860 yield* post(
861 "/restore",
862 { Key, VersionId: versions.old },
863 Schema.Struct({
864 tag: Schema.Literal("accepted"),
865 versionId: Schema.String,
866 }),
867 ),
868 ).toEqual({ tag: "accepted", versionId: versions.old });
869
870 // Poll only for restore-request visibility, never for retrieval completion.
871 const restored = yield* S3.headObject({
872 Bucket,
873 Key,
874 VersionId: versions.old,
875 }).pipe(
876 Effect.repeat({
877 until: (head) => head.Restore !== undefined,
878 schedule: Schedule.spaced("2 seconds"),
879 times: 9,
880 }),
881 );
882 expect(restored.VersionId).toBe(versions.old);
883 expect(restored.StorageClass).toBe("GLACIER");
884 expect(restored.ETag).toBe(archived.ETag);
885 expect(restored.ContentLength).toBe(archived.ContentLength);
886 expect(restored.Restore).toMatch(
887 /ongoing-request="(?:true|false)"/,
888 );
889 const pending = restored.Restore!.includes(
890 'ongoing-request="true"',
891 );
892 if (!pending) {
893 expect(restored.Restore).toContain('expiry-date="');
894 }
895 yield* Effect.logInfo(
896 `RestoreObject accepted for ${versions.old}; retrieval ${pending ? "pending" : "completed"}`,
897 );
898
899 for (const VersionId of [undefined, versions.current]) {
900 const latest = yield* S3.headObject({ Bucket, Key, VersionId });
901 expect(latest.VersionId).toBe(versions.current);
902 expect(latest.StorageClass ?? "STANDARD").toBe("STANDARD");
903 expect(latest.ContentLength).toBe(versions.currentBody.length);
904 expect(latest.Restore).toBeUndefined();
905 }
906 const current = yield* S3.getObject({ Bucket, Key });
907 expect(current.VersionId).toBe(versions.current);
908 expect(current.Body).toBeDefined();
909 expect(
910 yield* current.Body!.pipe(Stream.decodeText, Stream.mkString),
911 ).toBe(versions.currentBody);
912 const listed = yield* S3.listObjectVersions({
913 Bucket,
914 Prefix: Key,
915 });
916 expect(
917 listed.Versions?.filter((version) => version.Key === Key)
918 .map((version) => ({
919 id: version.VersionId,
920 latest: version.IsLatest,
921 storageClass: version.StorageClass,
922 }))
923 .sort((a, b) => a.id!.localeCompare(b.id!)),
924 ).toEqual(
925 [
926 { id: versions.old, latest: false, storageClass: "GLACIER" },
927 {
928 id: versions.current,
929 latest: true,
930 storageClass: "STANDARD",
931 },
932 ].sort((a, b) => a.id.localeCompare(b.id)),
933 );
934 }),
935 { timeout: 120_000, retry: 0 },
936 );
937 });
938 },
939);