1import { AlchemyContext } from "@/AlchemyContext.ts";
2import { ArtifactStore, createArtifactStore } from "@/Artifacts.ts";
3import * as AWS from "@/AWS";
4import { AWSEnvironment } from "@/AWS/Environment.ts";
5import { Role } from "@/AWS/IAM";
6import { Bucket } from "@/AWS/S3";
7import { BucketProvider } from "@/AWS/S3/Bucket.ts";
8import { InstanceId } from "@/InstanceId.ts";
9import * as Provider from "@/Provider";
10import { Stack, type StackSpec } from "@/Stack.ts";
11import { Stage } from "@/Stage.ts";
12import { State } from "@/State";
13import * as Test from "@/Test/Alchemy";
14import { inMemoryState } from "@/State";
15import { Credentials, fromCredentials } from "@distilled.cloud/aws/Credentials";
16import { Region } from "@distilled.cloud/aws/Region";
17import * as KMS from "@distilled.cloud/aws/kms";
18import * as S3 from "@distilled.cloud/aws/s3";
19import { NodeServices } from "@effect/platform-node";
20import { describe, expect, it } from "alchemy-test";
21import * as Data from "effect/Data";
22import * as Effect from "effect/Effect";
23import * as Layer from "effect/Layer";
24import * as Redacted from "effect/Redacted";
25import * as Result from "effect/Result";
26import * as Schedule from "effect/Schedule";
27import * as FetchHttpClient from "effect/http/FetchHttpClient";
28import * as HttpClient from "effect/http/HttpClient";
29
30const { test } = Test.make({ providers: AWS.providers() });
31
32test.provider(
33 "PR1585 discovers an owned bucket without ListBucket and rejects denied location reads",
34 (stack) =>
35 Effect.gen(function* () {
36 yield* stack.destroy();
37 const bucket = yield* stack.deploy(Bucket("OwnershipBucket", {}));
38 const { accountId } = yield* AWSEnvironment.current;
39 const definition = (value: string) =>
40 Bucket("OwnershipBucket", { tags: { revision: value } });
41 const discovery = Effect.gen(function* () {
42 yield* definition("updated");
43 return yield* Bucket("DiscoveryProbe", {
44 bucketName: bucket.bucketName,
45 });
46 });
47
48 const deny = (action: string) =>
49 S3.putBucketPolicy({
50 Bucket: bucket.bucketName,
51 Policy: JSON.stringify({
52 Version: "2012-10-17",
53 Statement: [
54 {
55 Effect: "Deny",
56 Principal: "*",
57 Action: action,
58 Resource: bucket.bucketArn,
59 },
60 ],
61 }),
62 });
63 const restorePolicy = S3.deleteBucketPolicy({
64 Bucket: bucket.bucketName,
65 });
66
67 yield* Effect.gen(function* () {
68 yield* deny("s3:ListBucket");
69 const listDenied = yield* S3.listObjectsV2({
70 Bucket: bucket.bucketName,
71 }).pipe(
72 Effect.as(false),
73 Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
74 Effect.repeat({
75 until: Boolean,
76 schedule: Schedule.spaced("1 second"),
77 times: 8,
78 }),
79 );
80 expect(listDenied).toBe(true);
81 yield* S3.getBucketLocation({
82 Bucket: bucket.bucketName,
83 ExpectedBucketOwner: accountId,
84 });
85
86 // The probe is planned, not applied: the original resource retains ownership.
87 const plan = yield* stack.plan(discovery);
88 expect(plan.resources.DiscoveryProbe?.action).toBe("adopted");
89 yield* stack.deploy(definition("updated"));
90 expect(
91 (yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
92 ).toContainEqual({ Key: "revision", Value: "updated" });
93
94 const wrongOwner = `${accountId.slice(0, -1)}${accountId.endsWith("0") ? "1" : "0"}`;
95 const mismatch = yield* S3.getBucketLocation({
96 Bucket: bucket.bucketName,
97 ExpectedBucketOwner: wrongOwner,
98 }).pipe(Effect.flip);
99 expect(mismatch._tag).toBe("AccessDeniedException");
100
101 yield* deny("s3:GetBucketLocation");
102 const locationDenied = yield* S3.getBucketLocation({
103 Bucket: bucket.bucketName,
104 ExpectedBucketOwner: accountId,
105 }).pipe(
106 Effect.as(false),
107 Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
108 Effect.repeat({
109 until: Boolean,
110 schedule: Schedule.spaced("2 seconds"),
111 times: 10,
112 }),
113 );
114 expect(locationDenied).toBe(true);
115 // HeadBucket remains allowed; treating this rejection as absence would plan a create.
116 yield* S3.listObjectsV2({ Bucket: bucket.bucketName }).pipe(
117 Effect.retry({
118 while: (error) => error._tag === "AccessDeniedException",
119 schedule: Schedule.spaced("1 second"),
120 times: 8,
121 }),
122 );
123 const result = yield* stack.plan(discovery).pipe(
124 Effect.result,
125 Effect.repeat({
126 until: Result.isFailure,
127 schedule: Schedule.spaced("2 seconds"),
128 times: 10,
129 }),
130 );
131 expect(Result.isFailure(result)).toBe(true);
132 if (Result.isFailure(result)) {
133 expect(result.failure._tag).toBe("AccessDeniedException");
134 }
135 }).pipe(Effect.ensuring(restorePolicy.pipe(Effect.orDie)));
136
137 yield* S3.getBucketLocation({
138 Bucket: bucket.bucketName,
139 ExpectedBucketOwner: accountId,
140 }).pipe(
141 Effect.retry({
142 while: (error) => error._tag === "AccessDeniedException",
143 schedule: Schedule.spaced("1 second"),
144 times: 8,
145 }),
146 );
147 yield* stack.destroy();
148 yield* assertBucketDeleted(bucket.bucketName);
149 const absent = yield* S3.getBucketLocation({
150 Bucket: bucket.bucketName,
151 }).pipe(
152 Effect.as(false),
153 Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
154 Effect.repeat({
155 until: Boolean,
156 schedule: Schedule.spaced("1 second"),
157 times: 8,
158 }),
159 );
160 expect(absent).toBe(true);
161 }),
162 { tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
163);
164
165for (const aspect of ["tagging", "encryption"] as const) {
166 test.provider(
167 `PR1586 preserves ${aspect} when its read is denied but writes are allowed`,
168 (stack) =>
169 Effect.gen(function* () {
170 yield* stack.destroy();
171 const initialProps = {
172 tags: { revision: "before" },
173 encryption: { sseAlgorithm: "AES256" as const },
174 };
175 const bucket = yield* stack.deploy(
176 Bucket("ReadFailureBucket", initialProps),
177 );
178 const tagging = yield* S3.getBucketTagging({
179 Bucket: bucket.bucketName,
180 });
181 const encryption = yield* S3.getBucketEncryption({
182 Bucket: bucket.bucketName,
183 });
184 const restorePolicy = S3.deleteBucketPolicy({
185 Bucket: bucket.bucketName,
186 });
187 const read = Effect.gen(function* () {
188 if (aspect === "tagging") {
189 yield* S3.getBucketTagging({ Bucket: bucket.bucketName });
190 } else {
191 yield* S3.getBucketEncryption({ Bucket: bucket.bucketName });
192 }
193 });
194 const desired = Bucket("ReadFailureBucket", {
195 tags: { revision: "after" },
196 encryption: {
197 sseAlgorithm: aspect === "encryption" ? "aws:kms" : "AES256",
198 },
199 });
200
201 yield* Effect.gen(function* () {
202 yield* S3.putBucketPolicy({
203 Bucket: bucket.bucketName,
204 Policy: JSON.stringify({
205 Version: "2012-10-17",
206 Statement: [
207 {
208 Effect: "Deny",
209 Principal: "*",
210 Action:
211 aspect === "tagging"
212 ? "s3:GetBucketTagging"
213 : "s3:GetEncryptionConfiguration",
214 Resource: bucket.bucketArn,
215 },
216 ],
217 }),
218 });
219 const denied = yield* read.pipe(
220 Effect.as(false),
221 Effect.catchTag("AccessDeniedException", () =>
222 Effect.succeed(true),
223 ),
224 Effect.repeat({
225 until: Boolean,
226 schedule: Schedule.spaced("1 second"),
227 times: 8,
228 }),
229 );
230 expect(denied).toBe(true);
231 // Reapply the observed configuration to prove the write permission remains usable.
232 if (aspect === "tagging") {
233 yield* S3.putBucketTagging({
234 Bucket: bucket.bucketName,
235 Tagging: { TagSet: tagging.TagSet },
236 });
237 } else {
238 yield* S3.putBucketEncryption({
239 Bucket: bucket.bucketName,
240 ServerSideEncryptionConfiguration:
241 encryption.ServerSideEncryptionConfiguration!,
242 });
243 }
244 const planningFailure = yield* stack.plan(desired).pipe(Effect.flip);
245 expect(planningFailure._tag).toBe("AccessDeniedException");
246 const failure = yield* stack.deploy(desired).pipe(Effect.flip);
247 expect(failure._tag).toBe("AccessDeniedException");
248 }).pipe(Effect.ensuring(restorePolicy.pipe(Effect.orDie)));
249
250 yield* read.pipe(
251 Effect.retry({
252 while: (error) => error._tag === "AccessDeniedException",
253 schedule: Schedule.spaced("1 second"),
254 times: 8,
255 }),
256 );
257 if (aspect === "tagging") {
258 expect(
259 (yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
260 ).toEqual(tagging.TagSet);
261 } else {
262 expect(
263 (yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
264 .ServerSideEncryptionConfiguration,
265 ).toEqual(encryption.ServerSideEncryptionConfiguration);
266 }
267 yield* stack.deploy(desired);
268 if (aspect === "tagging") {
269 expect(
270 (yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
271 ).toContainEqual({ Key: "revision", Value: "after" });
272 } else {
273 expect(
274 (yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
275 .ServerSideEncryptionConfiguration?.Rules[0]
276 ?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm,
277 ).toBe("aws:kms");
278 }
279 yield* stack.destroy();
280 yield* assertBucketDeleted(bucket.bucketName);
281 }),
282 { tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
283 );
284}
285
286test.provider(
287 "PR1586 initializes tags after a real NoSuchTagSet response",
288 (stack) =>
289 Effect.gen(function* () {
290 yield* stack.destroy();
291 const bucket = yield* stack.deploy(Bucket("UntaggedBucket", {}));
292 const absent = yield* S3.getBucketTagging({
293 Bucket: bucket.bucketName,
294 }).pipe(Effect.flip);
295 expect(absent._tag).toBe("NoSuchTagSet");
296 yield* stack.deploy(
297 Bucket("UntaggedBucket", { tags: { initialized: "yes" } }),
298 );
299 expect(
300 (yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
301 ).toEqual([{ Key: "initialized", Value: "yes" }]);
302 yield* stack.destroy();
303 yield* assertBucketDeleted(bucket.bucketName);
304 }),
305 { tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
306);
307
308test.provider(
309 "PR1587 skips encryption writes for a decoded KMS ARN and still updates keys",
310 (stack) =>
311 Effect.gen(function* () {
312 yield* stack.destroy();
313 const definition = (
314 revision: string,
315 key: "first" | "second" | undefined,
316 bucketKeyEnabled = false,
317 policy?: AWS.IAM.PolicyStatement[],
318 ) =>
319 Effect.gen(function* () {
320 const first = yield* AWS.KMS.Key("FirstKey", {
321 deletionWindow: "7 days",
322 });
323 const second = yield* AWS.KMS.Key("SecondKey", {
324 deletionWindow: "7 days",
325 });
326 const bucket = yield* Bucket("KmsIdentityBucket", {
327 tags: { revision },
328 policy,
329 encryption:
330 key === undefined
331 ? undefined
332 : {
333 sseAlgorithm: "aws:kms",
334 kmsMasterKeyId:
335 key === "first" ? first.keyArn : second.keyArn,
336 bucketKeyEnabled,
337 },
338 });
339 return { bucket, first, second };
340 });
341 const initial = yield* stack.deploy(definition("before", "first"));
342 const { bucket, first, second } = initial;
343 const observed = yield* S3.getBucketEncryption({
344 Bucket: bucket.bucketName,
345 });
346 const decoded =
347 observed.ServerSideEncryptionConfiguration!.Rules[0]!
348 .ApplyServerSideEncryptionByDefault!.KMSMasterKeyID;
349 expect(Redacted.isRedacted(decoded)).toBe(true);
350 expect(
351 Redacted.isRedacted(decoded) ? Redacted.value(decoded) : decoded,
352 ).toBe(first.keyArn);
353 const probeWrite = S3.putBucketEncryption({
354 Bucket: bucket.bucketName,
355 ServerSideEncryptionConfiguration:
356 observed.ServerSideEncryptionConfiguration!,
357 }).pipe(
358 Effect.as(false),
359 Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
360 );
361
362 const deny: AWS.IAM.PolicyStatement[] = [
363 {
364 Effect: "Deny",
365 Principal: { AWS: "*" },
366 Action: ["s3:PutEncryptionConfiguration"],
367 Resource: bucket.bucketArn,
368 },
369 ];
370 yield* Effect.gen(function* () {
371 yield* S3.putBucketPolicy({
372 Bucket: bucket.bucketName,
373 Policy: JSON.stringify({ Version: "2012-10-17", Statement: deny }),
374 });
375 expect(
376 yield* probeWrite.pipe(
377 Effect.repeat({
378 until: Boolean,
379 schedule: Schedule.spaced("1 second"),
380 times: 8,
381 }),
382 ),
383 ).toBe(true);
384 const plan = yield* stack.plan(
385 definition("after", "first", false, deny),
386 );
387 expect(plan.resources.KmsIdentityBucket?.action).toBe("update");
388 // Tags force reconcile; an encryption PUT would fail under the proven deny.
389 yield* stack.deploy(definition("after", "first", false, deny));
390 expect(
391 (yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
392 ).toContainEqual({ Key: "revision", Value: "after" });
393 expect(yield* probeWrite).toBe(true);
394 }).pipe(
395 Effect.ensuring(
396 S3.deleteBucketPolicy({ Bucket: bucket.bucketName }).pipe(
397 Effect.orDie,
398 ),
399 ),
400 );
401
402 // Wait for removal using the original configuration, never the desired next key.
403 yield* S3.putBucketEncryption({
404 Bucket: bucket.bucketName,
405 ServerSideEncryptionConfiguration:
406 observed.ServerSideEncryptionConfiguration!,
407 }).pipe(
408 Effect.retry({
409 while: (error) => error._tag === "AccessDeniedException",
410 schedule: Schedule.spaced("1 second"),
411 times: 8,
412 }),
413 );
414 yield* stack.deploy(definition("different-key", "second"));
415 const changed = (yield* S3.getBucketEncryption({
416 Bucket: bucket.bucketName,
417 })).ServerSideEncryptionConfiguration!.Rules[0]!
418 .ApplyServerSideEncryptionByDefault!.KMSMasterKeyID;
419 expect(
420 Redacted.isRedacted(changed) ? Redacted.value(changed) : changed,
421 ).toBe(second.keyArn);
422 yield* stack.deploy(definition("bucket-key", "second", true));
423 expect(
424 (yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
425 .ServerSideEncryptionConfiguration?.Rules[0]?.BucketKeyEnabled,
426 ).toBe(true);
427 const defaultsProgram = definition("bucket-key", undefined);
428 yield* stack.deploy(defaultsProgram);
429 const defaults = (yield* S3.getBucketEncryption({
430 Bucket: bucket.bucketName,
431 })).ServerSideEncryptionConfiguration!.Rules[0]!;
432 expect(defaults.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
433 "AES256",
434 );
435 expect(
436 defaults.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID,
437 ).toBeUndefined();
438 expect(defaults.BucketKeyEnabled ?? false).toBe(false);
439 expect(defaults.BlockedEncryptionTypes?.EncryptionType).toEqual(["NONE"]);
440 expect(
441 (yield* stack.plan(defaultsProgram)).resources.KmsIdentityBucket
442 ?.action,
443 ).toBe("noop");
444 yield* stack.destroy();
445 yield* assertBucketDeleted(bucket.bucketName);
446 for (const key of [first, second]) {
447 expect(
448 (yield* KMS.describeKey({ KeyId: key.keyId })).KeyMetadata?.KeyState,
449 ).toBe("PendingDeletion");
450 }
451 }),
452 {
453 tags: ["provider:aws", "provider:aws:kms", "provider:aws:s3", "live"],
454 timeout: 120_000,
455 },
456);
457
458for (const blocked of ["SSE-C", "NONE"] as const) {
459 test.provider(
460 `repairs external ${blocked} encryption settings with unchanged default inputs`,
461 (stack) =>
462 Effect.gen(function* () {
463 yield* stack.destroy();
464 const desired = Bucket("EncryptionBlocksBucket", {});
465 const bucket = yield* stack.deploy(desired);
466 expect(
467 (yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
468 .ServerSideEncryptionConfiguration?.Rules[0]?.BlockedEncryptionTypes
469 ?.EncryptionType,
470 ).toEqual(["NONE"]);
471 yield* S3.putBucketEncryption({
472 Bucket: bucket.bucketName,
473 ServerSideEncryptionConfiguration: {
474 Rules: [
475 {
476 ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "aws:kms" },
477 BlockedEncryptionTypes: { EncryptionType: [blocked] },
478 },
479 ],
480 },
481 });
482 const before = (yield* S3.getBucketEncryption({
483 Bucket: bucket.bucketName,
484 })).ServerSideEncryptionConfiguration!.Rules[0]!;
485 expect(before.BlockedEncryptionTypes?.EncryptionType).toEqual([
486 blocked,
487 ]);
488 expect(before.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
489 "aws:kms",
490 );
491 const plan = yield* stack.plan(desired);
492 expect(plan.resources.EncryptionBlocksBucket?.action).toBe("update");
493 yield* stack.deploy(desired);
494 const after = (yield* S3.getBucketEncryption({
495 Bucket: bucket.bucketName,
496 })).ServerSideEncryptionConfiguration!.Rules[0]!;
497 expect(after.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
498 "AES256",
499 );
500 expect(
501 after.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID,
502 ).toBeUndefined();
503 expect(after.BucketKeyEnabled ?? false).toBe(false);
504 expect(after.BlockedEncryptionTypes?.EncryptionType).toEqual(["NONE"]);
505 expect(
506 (yield* stack.plan(desired)).resources.EncryptionBlocksBucket?.action,
507 ).toBe("noop");
508 yield* stack.destroy();
509 yield* assertBucketDeleted(bucket.bucketName);
510 }),
511 { tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
512 );
513}
514
515test.provider(
516 "manages encryption blocks and restores defaults on property removal",
517 (stack) =>
518 Effect.gen(function* () {
519 yield* stack.destroy();
520 const program = (
521 blockedEncryptionTypes: AWS.S3.BucketEncryption["blockedEncryptionTypes"],
522 revision: string,
523 policy?: AWS.IAM.PolicyStatement[],
524 ) =>
525 Bucket("ManagedEncryptionBlocks", {
526 encryption: { sseAlgorithm: "AES256", blockedEncryptionTypes },
527 tags: { revision },
528 policy,
529 });
530 const bucket = yield* stack.deploy(program([], "managed"));
531 const readRule = S3.getBucketEncryption({
532 Bucket: bucket.bucketName,
533 }).pipe(
534 Effect.map(
535 (result) => result.ServerSideEncryptionConfiguration!.Rules[0]!,
536 ),
537 );
538 expect((yield* readRule).BlockedEncryptionTypes?.EncryptionType).toEqual([
539 "NONE",
540 ]);
541 const deny: AWS.IAM.PolicyStatement[] = [
542 {
543 Effect: "Deny",
544 Principal: { AWS: "*" },
545 Action: ["s3:PutEncryptionConfiguration"],
546 Resource: bucket.bucketArn,
547 },
548 ];
549 const settings: "SSE-C"[][] = [["SSE-C"], []];
550 for (const types of settings) {
551 const probeWrite = S3.putBucketEncryption({
552 Bucket: bucket.bucketName,
553 ServerSideEncryptionConfiguration: {
554 Rules: [
555 {
556 ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" },
557 BucketKeyEnabled: false,
558 BlockedEncryptionTypes: {
559 EncryptionType: types.length ? types : ["NONE"],
560 },
561 },
562 ],
563 },
564 }).pipe(
565 Effect.as(false),
566 Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
567 );
568 const desired = program(types, "managed");
569 expect(
570 (yield* stack.plan(desired)).resources.ManagedEncryptionBlocks
571 ?.action,
572 ).toBe("update");
573 const updated = yield* stack.deploy(desired);
574 expect(updated.bucketName).toBe(bucket.bucketName);
575 const expected = types.length ? ["SSE-C"] : ["NONE"];
576 expect(
577 (yield* readRule).BlockedEncryptionTypes?.EncryptionType,
578 ).toEqual(expected);
579 yield* Effect.gen(function* () {
580 yield* S3.putBucketPolicy({
581 Bucket: bucket.bucketName,
582 Policy: JSON.stringify({ Version: "2012-10-17", Statement: deny }),
583 });
584 expect(
585 yield* probeWrite.pipe(
586 Effect.repeat({
587 until: Boolean,
588 schedule: Schedule.spaced("1 second"),
589 times: 8,
590 }),
591 ),
592 ).toBe(true);
593 // Tags force reconcile while equivalent restrictions must skip the denied PUT.
594 yield* stack.deploy(program([...types, ...types], "unchanged", deny));
595 if (!types.length) {
596 yield* stack.deploy(program(undefined, "omitted", deny));
597 }
598 expect(yield* probeWrite).toBe(true);
599 expect(
600 (yield* readRule).BlockedEncryptionTypes?.EncryptionType,
601 ).toEqual(expected);
602 }).pipe(
603 Effect.ensuring(
604 S3.deleteBucketPolicy({ Bucket: bucket.bucketName }).pipe(
605 Effect.orDie,
606 ),
607 ),
608 );
609 expect(
610 yield* probeWrite.pipe(
611 Effect.repeat({
612 until: (denied) => !denied,
613 schedule: Schedule.spaced("1 second"),
614 times: 8,
615 }),
616 ),
617 ).toBe(false);
618 yield* stack.deploy(program(undefined, "default"));
619 expect(
620 (yield* readRule).BlockedEncryptionTypes?.EncryptionType,
621 ).toEqual(["NONE"]);
622 }
623 const desired = program([], "repair");
624 yield* stack.deploy(desired);
625 yield* S3.putBucketEncryption({
626 Bucket: bucket.bucketName,
627 ServerSideEncryptionConfiguration: {
628 Rules: [
629 {
630 ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" },
631 BlockedEncryptionTypes: { EncryptionType: ["SSE-C"] },
632 },
633 ],
634 },
635 });
636 expect(
637 (yield* stack.plan(desired)).resources.ManagedEncryptionBlocks?.action,
638 ).toBe("update");
639 yield* stack.deploy(desired);
640 expect((yield* readRule).BlockedEncryptionTypes?.EncryptionType).toEqual([
641 "NONE",
642 ]);
643 expect(
644 (yield* stack.plan(program([], "repair"))).resources
645 .ManagedEncryptionBlocks?.action,
646 ).toBe("noop");
647 yield* stack.destroy();
648 const absent = yield* S3.getBucketLocation({
649 Bucket: bucket.bucketName,
650 }).pipe(
651 Effect.as(false),
652 Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
653 Effect.repeat({
654 until: Boolean,
655 schedule: Schedule.spaced("1 second"),
656 times: 8,
657 }),
658 );
659 expect(absent).toBe(true);
660 }),
661 { tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
662);
663
664test.provider(
665 "create and delete bucket with default props",
666 (stack) =>
667 Effect.gen(function* () {
668 yield* stack.destroy();
669
670 const bucket = yield* stack.deploy(
671 Effect.gen(function* () {
672 return yield* Bucket("DefaultBucket");
673 }),
674 );
675
676 expect(bucket.bucketName).toBeDefined();
677 expect(bucket.bucketArn).toBeDefined();
678 expect(bucket.region).toBeDefined();
679
680 yield* S3.headBucket({ Bucket: bucket.bucketName });
681
682 yield* stack.destroy();
683
684 yield* assertBucketDeleted(bucket.bucketName);
685 }),
686 { tags: ["provider:aws", "provider:aws:s3", "live"] },
687);
688
689test.provider(
690 "create, update, delete bucket",
691 (stack) =>
692 Effect.gen(function* () {
693 yield* stack.destroy();
694
695 const bucket = yield* stack.deploy(
696 Effect.gen(function* () {
697 return yield* Bucket("TestBucket", {
698 bucketName: "alchemy-test-bucket-crud",
699 tags: { Environment: "test" },
700 forceDestroy: true,
701 });
702 }),
703 );
704
705 yield* S3.headBucket({ Bucket: bucket.bucketName });
706
707 const tagging = yield* S3.getBucketTagging({
708 Bucket: bucket.bucketName,
709 });
710 expect(tagging.TagSet).toContainEqual({
711 Key: "Environment",
712 Value: "test",
713 });
714
715 yield* stack.deploy(
716 Effect.gen(function* () {
717 return yield* Bucket("TestBucket", {
718 bucketName: "alchemy-test-bucket-crud",
719 tags: { Environment: "production", Team: "platform" },
720 forceDestroy: true,
721 });
722 }),
723 );
724
725 const updatedTagging = yield* S3.getBucketTagging({
726 Bucket: bucket.bucketName,
727 });
728 expect(updatedTagging.TagSet).toContainEqual({
729 Key: "Environment",
730 Value: "production",
731 });
732 expect(updatedTagging.TagSet).toContainEqual({
733 Key: "Team",
734 Value: "platform",
735 });
736
737 yield* stack.destroy();
738
739 yield* assertBucketDeleted(bucket.bucketName);
740 }),
741 { tags: ["provider:aws", "provider:aws:s3", "live"] },
742);
743
744test.provider(
745 "create bucket with custom name",
746 (stack) =>
747 Effect.gen(function* () {
748 yield* stack.destroy();
749
750 const bucket = yield* stack.deploy(
751 Effect.gen(function* () {
752 return yield* Bucket("CustomNameBucket", {
753 bucketName: "alchemy-test-bucket-custom-name",
754 forceDestroy: true,
755 });
756 }),
757 );
758
759 expect(bucket.bucketName).toEqual("alchemy-test-bucket-custom-name");
760 expect(bucket.bucketArn).toEqual(
761 "arn:aws:s3:::alchemy-test-bucket-custom-name",
762 );
763
764 yield* S3.headBucket({ Bucket: bucket.bucketName });
765
766 yield* stack.destroy();
767
768 yield* assertBucketDeleted(bucket.bucketName);
769 }),
770 { tags: ["provider:aws", "provider:aws:s3", "live"] },
771);
772
773test.provider(
774 "create bucket with forceDestroy",
775 (stack) =>
776 Effect.gen(function* () {
777 yield* stack.destroy();
778
779 const bucket = yield* stack.deploy(
780 Effect.gen(function* () {
781 return yield* Bucket("ForceDestroyBucket", {
782 bucketName: "alchemy-test-bucket-force-destroy",
783 forceDestroy: true,
784 });
785 }),
786 );
787
788 yield* S3.putObject({
789 Bucket: bucket.bucketName,
790 Key: "test-object.txt",
791 Body: "Hello, World!",
792 });
793
794 // A heavily parallel account sweep can briefly observe the successful
795 // PutObject before the object is readable through a different S3
796 // endpoint. Retry only the typed visibility miss, with a hard bound;
797 // authorization and request-shape errors still fail immediately.
798 yield* S3.headObject({
799 Bucket: bucket.bucketName,
800 Key: "test-object.txt",
801 }).pipe(
802 Effect.retry({
803 while: (error) => error._tag === "NotFound",
804 schedule: Schedule.max([
805 Schedule.exponential(100),
806 Schedule.recurs(6),
807 ]),
808 }),
809 );
810
811 yield* stack.destroy();
812
813 yield* assertBucketDeleted(bucket.bucketName);
814 }),
815 { tags: ["provider:aws", "provider:aws:s3", "live"] },
816);
817
818test.provider(
819 "idempotent create - bucket already exists",
820 (stack) =>
821 Effect.gen(function* () {
822 yield* stack.destroy();
823
824 const bucket1 = yield* stack.deploy(
825 Effect.gen(function* () {
826 return yield* Bucket("IdempotentBucket", {
827 bucketName: "alchemy-test-bucket-idempotent",
828 forceDestroy: true,
829 });
830 }),
831 );
832 const bucketName = bucket1.bucketName;
833
834 const bucket2 = yield* stack.deploy(
835 Effect.gen(function* () {
836 return yield* Bucket("IdempotentBucket", {
837 bucketName: "alchemy-test-bucket-idempotent",
838 forceDestroy: true,
839 });
840 }),
841 );
842 expect(bucket2.bucketName).toEqual(bucketName);
843
844 yield* stack.destroy();
845
846 yield* assertBucketDeleted(bucketName);
847 }),
848 { tags: ["provider:aws", "provider:aws:s3", "live"] },
849);
850
851test.provider(
852 "create bucket with objectLockEnabled",
853 (stack) =>
854 Effect.gen(function* () {
855 yield* stack.destroy();
856
857 const bucket = yield* stack.deploy(
858 Effect.gen(function* () {
859 return yield* Bucket("ObjectLockBucket", {
860 bucketName: "alchemy-test-bucket-object-lock",
861 objectLockEnabled: true,
862 forceDestroy: true,
863 });
864 }),
865 );
866
867 const objectLockConfig = yield* S3.getObjectLockConfiguration({
868 Bucket: bucket.bucketName,
869 });
870 expect(
871 objectLockConfig.ObjectLockConfiguration?.ObjectLockEnabled,
872 ).toEqual("Enabled");
873
874 yield* stack.destroy();
875
876 yield* assertBucketDeleted(bucket.bucketName);
877 }),
878 { tags: ["provider:aws", "provider:aws:s3", "live"] },
879);
880
881test.provider(
882 "remove all tags from bucket",
883 (stack) =>
884 Effect.gen(function* () {
885 yield* stack.destroy();
886
887 const bucket = yield* stack.deploy(
888 Effect.gen(function* () {
889 return yield* Bucket("TagRemovalBucket", {
890 bucketName: "alchemy-test-bucket-tag-removal",
891 tags: { Environment: "test", Team: "platform" },
892 forceDestroy: true,
893 });
894 }),
895 );
896 const bucketName = bucket.bucketName;
897
898 const tagging = yield* S3.getBucketTagging({
899 Bucket: bucketName,
900 });
901 expect(tagging.TagSet).toHaveLength(2);
902
903 yield* stack.deploy(
904 Effect.gen(function* () {
905 return yield* Bucket("TagRemovalBucket", {
906 bucketName: "alchemy-test-bucket-tag-removal",
907 forceDestroy: true,
908 });
909 }),
910 );
911
912 const result = yield* S3.getBucketTagging({
913 Bucket: bucketName,
914 }).pipe(
915 Effect.map(() => "has-tags" as const),
916 Effect.catchTag("NoSuchTagSet", () =>
917 Effect.succeed("no-tags" as const),
918 ),
919 );
920 expect(result).toEqual("no-tags");
921
922 yield* stack.destroy();
923
924 yield* assertBucketDeleted(bucketName);
925 }),
926 { tags: ["provider:aws", "provider:aws:s3", "live"] },
927);
928
929test.provider(
930 "create and remove bucket policy from bindings",
931 (stack) =>
932 Effect.gen(function* () {
933 yield* stack.destroy();
934
935 const distributionArn =
936 "arn:aws:cloudfront::123456789012:distribution/TESTDIST";
937 const bucketArn = "arn:aws:s3:::alchemy-test-bucket-policy-bindings";
938
939 const bucket = yield* stack.deploy(
940 Effect.gen(function* () {
941 const bucket = yield* Bucket("PolicyBucket", {
942 bucketName: "alchemy-test-bucket-policy-bindings",
943 forceDestroy: true,
944 });
945
946 yield* bucket.bind("AWS.S3.Policy(TestDistribution, PolicyBucket)", {
947 policyStatements: [
948 {
949 Effect: "Allow",
950 Principal: {
951 Service: "cloudfront.amazonaws.com",
952 },
953 Action: ["s3:GetObject"],
954 Resource: [`${bucketArn}/*`],
955 Condition: {
956 StringEquals: {
957 "AWS:SourceArn": distributionArn,
958 },
959 },
960 },
961 ],
962 });
963
964 return bucket;
965 }),
966 );
967
968 const bucketPolicy = yield* S3.getBucketPolicy({
969 Bucket: bucket.bucketName,
970 }).pipe(Effect.map((response) => JSON.parse(response.Policy!)));
971 const statement = bucketPolicy.Statement[0];
972
973 expect(bucketPolicy.Version).toEqual("2012-10-17");
974 expect(statement.Effect).toEqual("Allow");
975 expect(statement.Principal).toEqual({
976 Service: "cloudfront.amazonaws.com",
977 });
978 expect(statement.Action).toEqual("s3:GetObject");
979 expect(statement.Resource).toEqual(`${bucketArn}/*`);
980 expect(statement.Condition).toEqual({
981 StringEquals: {
982 "AWS:SourceArn": distributionArn,
983 },
984 });
985
986 yield* stack.deploy(
987 Effect.gen(function* () {
988 return yield* Bucket("PolicyBucket", {
989 bucketName: "alchemy-test-bucket-policy-bindings",
990 forceDestroy: true,
991 });
992 }),
993 );
994
995 const policyAfterRemoval = yield* S3.getBucketPolicy({
996 Bucket: bucket.bucketName,
997 }).pipe(
998 Effect.map(() => "has-policy" as const),
999 Effect.catchTag("NoSuchBucketPolicy", () =>
1000 Effect.succeed("no-policy" as const),
1001 ),
1002 );
1003
1004 expect(policyAfterRemoval).toEqual("no-policy");
1005
1006 yield* stack.destroy();
1007
1008 yield* assertBucketDeleted(bucket.bucketName);
1009 }),
1010 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1011);
1012
1013// Engine-level adoption: S3 has no per-stack ownership signal (we don't
1014// stamp alchemy tags on buckets — the canonical existence check is
1015// `headBucket`, which only succeeds when the bucket is owned by *this AWS
1016// account*). So a name match means we own it at the account level — silent
1017// adoption is correct for the cold-start case.
1018test.provider(
1019 "owned bucket (account-level) is silently adopted without --adopt",
1020 (stack) =>
1021 Effect.gen(function* () {
1022 yield* stack.destroy();
1023
1024 // Deterministic name: the state wipe below intentionally makes the
1025 // bucket invisible to the scratch stack's auto-destroy for a window, so
1026 // the name must be stable across runs — a re-run then reclaims any
1027 // orphan left by a previously-killed run instead of minting a new one.
1028 const bucketName = "alchemy-test-s3-adopt-bucket";
1029
1030 // Idempotent pre-clean: reclaim a leftover from a prior interrupted run.
1031 yield* deleteBucketIfExists(bucketName);
1032
1033 yield* Effect.gen(function* () {
1034 const initial = yield* stack.deploy(
1035 Effect.gen(function* () {
1036 return yield* Bucket("AdoptableBucket", {
1037 bucketName,
1038 forceDestroy: true,
1039 });
1040 }),
1041 );
1042 expect(initial.bucketName).toEqual(bucketName);
1043 yield* S3.putBucketEncryption({
1044 Bucket: bucketName,
1045 ServerSideEncryptionConfiguration: {
1046 Rules: [
1047 {
1048 ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "aws:kms" },
1049 BucketKeyEnabled: true,
1050 BlockedEncryptionTypes: { EncryptionType: ["NONE"] },
1051 },
1052 ],
1053 },
1054 });
1055
1056 // Wipe state — bucket stays in S3.
1057 yield* Effect.gen(function* () {
1058 const state = yield* yield* State;
1059 yield* state.delete({
1060 stack: stack.name,
1061 stage: stack.stage,
1062 fqn: "AdoptableBucket",
1063 });
1064 }).pipe(Effect.provide(stack.state));
1065
1066 const adopted = yield* stack.deploy(
1067 Effect.gen(function* () {
1068 return yield* Bucket("AdoptableBucket", {
1069 bucketName,
1070 forceDestroy: true,
1071 });
1072 }),
1073 );
1074
1075 expect(adopted.bucketArn).toEqual(initial.bucketArn);
1076 const defaults = (yield* S3.getBucketEncryption({ Bucket: bucketName }))
1077 .ServerSideEncryptionConfiguration!.Rules[0]!;
1078 expect(defaults.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
1079 "AES256",
1080 );
1081 expect(
1082 defaults.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID,
1083 ).toBeUndefined();
1084 expect(defaults.BucketKeyEnabled ?? false).toBe(false);
1085 expect(defaults.BlockedEncryptionTypes?.EncryptionType).toEqual([
1086 "NONE",
1087 ]);
1088
1089 yield* stack.destroy();
1090 yield* assertBucketDeleted(bucketName);
1091 }).pipe(
1092 // Guaranteed out-of-band cleanup: while state is wiped the bucket is
1093 // outside the scratch stack, so the framework's ensuring-destroy can't
1094 // see it. This finalizer runs on success, failure, AND interruption;
1095 // it's a no-op when stack.destroy already deleted the bucket.
1096 Effect.ensuring(deleteBucketIfExists(bucketName).pipe(Effect.ignore)),
1097 );
1098 }),
1099 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1100);
1101
1102// Canonical `list()` test (AWS account/region-scoped collection): deploy a
1103// real bucket, resolve the provider from context via `findProviderByType`,
1104// call `list()`, and assert the deployed bucket appears in the
1105// exhaustively-paginated result.
1106test.provider(
1107 "list enumerates the deployed bucket",
1108 (stack) =>
1109 Effect.gen(function* () {
1110 yield* stack.destroy();
1111
1112 const bucket = yield* stack.deploy(
1113 Effect.gen(function* () {
1114 return yield* Bucket("ListBucket", {
1115 bucketName: "alchemy-test-bucket-list",
1116 forceDestroy: true,
1117 });
1118 }),
1119 );
1120
1121 const provider = yield* Provider.findProvider(Bucket);
1122 const all = yield* provider.list();
1123
1124 expect(all.some((b) => b.bucketName === bucket.bucketName)).toBe(true);
1125
1126 yield* stack.destroy();
1127
1128 yield* assertBucketDeleted(bucket.bucketName);
1129 }),
1130 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1131);
1132
1133test.provider(
1134 "versioning enable then suspend",
1135 (stack) =>
1136 Effect.gen(function* () {
1137 yield* stack.destroy();
1138
1139 const name = "alchemy-test-bucket-versioning";
1140 const bucket = yield* stack.deploy(
1141 Bucket("VersioningBucket", {
1142 bucketName: name,
1143 versioning: "Enabled",
1144 forceDestroy: true,
1145 }),
1146 );
1147
1148 const v1 = yield* S3.getBucketVersioning({ Bucket: bucket.bucketName });
1149 expect(v1.Status).toEqual("Enabled");
1150
1151 yield* stack.deploy(
1152 Bucket("VersioningBucket", {
1153 bucketName: name,
1154 versioning: "Suspended",
1155 forceDestroy: true,
1156 }),
1157 );
1158
1159 const v2 = yield* S3.getBucketVersioning({ Bucket: bucket.bucketName });
1160 expect(v2.Status).toEqual("Suspended");
1161
1162 yield* stack.destroy();
1163 yield* assertBucketDeleted(bucket.bucketName);
1164 }),
1165 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1166);
1167
1168test.provider(
1169 "encryption SSE-S3 set and update bucketKey",
1170 (stack) =>
1171 Effect.gen(function* () {
1172 yield* stack.destroy();
1173
1174 const name = "alchemy-test-bucket-encryption";
1175 const bucket = yield* stack.deploy(
1176 Bucket("EncryptionBucket", {
1177 bucketName: name,
1178 encryption: { sseAlgorithm: "AES256" },
1179 forceDestroy: true,
1180 }),
1181 );
1182
1183 const e1 = yield* S3.getBucketEncryption({ Bucket: bucket.bucketName });
1184 expect(
1185 e1.ServerSideEncryptionConfiguration?.Rules?.[0]
1186 ?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm,
1187 ).toEqual("AES256");
1188
1189 yield* stack.deploy(
1190 Bucket("EncryptionBucket", {
1191 bucketName: name,
1192 encryption: { sseAlgorithm: "AES256", bucketKeyEnabled: true },
1193 forceDestroy: true,
1194 }),
1195 );
1196
1197 const e2 = yield* S3.getBucketEncryption({ Bucket: bucket.bucketName });
1198 expect(
1199 e2.ServerSideEncryptionConfiguration?.Rules?.[0]?.BucketKeyEnabled,
1200 ).toEqual(true);
1201
1202 yield* stack.destroy();
1203 yield* assertBucketDeleted(bucket.bucketName);
1204 }),
1205 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1206);
1207
1208test.provider(
1209 "public access block set, update, remove",
1210 (stack) =>
1211 Effect.gen(function* () {
1212 yield* stack.destroy();
1213
1214 const name = "alchemy-test-bucket-pab";
1215 const bucket = yield* stack.deploy(
1216 Bucket("PabBucket", {
1217 bucketName: name,
1218 publicAccessBlock: {
1219 blockPublicAcls: true,
1220 ignorePublicAcls: true,
1221 blockPublicPolicy: true,
1222 restrictPublicBuckets: true,
1223 },
1224 forceDestroy: true,
1225 }),
1226 );
1227
1228 const p1 = yield* S3.getPublicAccessBlock({ Bucket: bucket.bucketName });
1229 expect(p1.PublicAccessBlockConfiguration?.BlockPublicAcls).toEqual(true);
1230 expect(p1.PublicAccessBlockConfiguration?.RestrictPublicBuckets).toEqual(
1231 true,
1232 );
1233
1234 yield* stack.deploy(
1235 Bucket("PabBucket", {
1236 bucketName: name,
1237 publicAccessBlock: {
1238 blockPublicAcls: true,
1239 ignorePublicAcls: false,
1240 blockPublicPolicy: true,
1241 restrictPublicBuckets: false,
1242 },
1243 forceDestroy: true,
1244 }),
1245 );
1246
1247 const p2 = yield* S3.getPublicAccessBlock({ Bucket: bucket.bucketName });
1248 expect(p2.PublicAccessBlockConfiguration?.RestrictPublicBuckets).toEqual(
1249 false,
1250 );
1251
1252 yield* stack.destroy();
1253 yield* assertBucketDeleted(bucket.bucketName);
1254 }),
1255 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1256);
1257
1258test.provider(
1259 "cors add rule then remove all",
1260 (stack) =>
1261 Effect.gen(function* () {
1262 yield* stack.destroy();
1263
1264 const name = "alchemy-test-bucket-cors";
1265 const bucket = yield* stack.deploy(
1266 Bucket("CorsBucket", {
1267 bucketName: name,
1268 cors: [
1269 {
1270 AllowedMethods: ["GET"],
1271 AllowedOrigins: ["https://example.com"],
1272 AllowedHeaders: ["*"],
1273 MaxAgeSeconds: 3000,
1274 },
1275 ],
1276 forceDestroy: true,
1277 }),
1278 );
1279
1280 const c1 = yield* S3.getBucketCors({ Bucket: bucket.bucketName });
1281 expect(c1.CORSRules).toHaveLength(1);
1282
1283 yield* stack.deploy(
1284 Bucket("CorsBucket", {
1285 bucketName: name,
1286 cors: [
1287 {
1288 AllowedMethods: ["GET"],
1289 AllowedOrigins: ["https://example.com"],
1290 AllowedHeaders: ["*"],
1291 MaxAgeSeconds: 3000,
1292 },
1293 {
1294 AllowedMethods: ["PUT", "POST"],
1295 AllowedOrigins: ["https://app.example.com"],
1296 },
1297 ],
1298 forceDestroy: true,
1299 }),
1300 );
1301
1302 const c2 = yield* S3.getBucketCors({ Bucket: bucket.bucketName });
1303 expect(c2.CORSRules).toHaveLength(2);
1304
1305 yield* stack.deploy(
1306 Bucket("CorsBucket", {
1307 bucketName: name,
1308 cors: [],
1309 forceDestroy: true,
1310 }),
1311 );
1312
1313 const removed = yield* S3.getBucketCors({
1314 Bucket: bucket.bucketName,
1315 }).pipe(
1316 Effect.map(() => "has-cors" as const),
1317 Effect.catchTag("NoSuchCORSConfiguration", () =>
1318 Effect.succeed("no-cors" as const),
1319 ),
1320 );
1321 expect(removed).toEqual("no-cors");
1322
1323 yield* stack.destroy();
1324 yield* assertBucketDeleted(bucket.bucketName);
1325 }),
1326 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1327);
1328
1329test.provider(
1330 "lifecycle add rule then remove",
1331 (stack) =>
1332 Effect.gen(function* () {
1333 yield* stack.destroy();
1334
1335 const name = "alchemy-test-bucket-lifecycle";
1336 const bucket = yield* stack.deploy(
1337 Bucket("LifecycleBucket", {
1338 bucketName: name,
1339 lifecycleRules: [
1340 {
1341 ID: "expire-logs",
1342 Status: "Enabled",
1343 Filter: { Prefix: "logs/" },
1344 Expiration: { Days: 30 },
1345 AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 },
1346 },
1347 ],
1348 forceDestroy: true,
1349 }),
1350 );
1351
1352 const l1 = yield* S3.getBucketLifecycleConfiguration({
1353 Bucket: bucket.bucketName,
1354 });
1355 expect(l1.Rules).toHaveLength(1);
1356 expect(l1.Rules?.[0]?.Expiration?.Days).toEqual(30);
1357
1358 yield* stack.deploy(
1359 Bucket("LifecycleBucket", {
1360 bucketName: name,
1361 lifecycleRules: [],
1362 forceDestroy: true,
1363 }),
1364 );
1365
1366 // Lifecycle config is eventually consistent — the rule can linger on reads
1367 // for a few seconds after deleteBucketLifecycle. Retry until it clears.
1368 const removed = yield* S3.getBucketLifecycleConfiguration({
1369 Bucket: bucket.bucketName,
1370 }).pipe(
1371 Effect.map(() => "has-lifecycle" as const),
1372 Effect.catchTag("NoSuchLifecycleConfiguration", () =>
1373 Effect.succeed("no-lifecycle" as const),
1374 ),
1375 Effect.repeat({
1376 schedule: Schedule.spaced("3 seconds"),
1377 until: (r) => r === "no-lifecycle",
1378 // S3 lifecycle deletion can take well over 30s to become visible on
1379 // reads. Give it ~90s (30 × 3s) before failing — the loop short-circuits
1380 // via `until` the moment the config clears, so this only costs wall-clock
1381 // time on the (rare) slow-propagation runs that were flaking at times: 10.
1382 times: 30,
1383 }),
1384 );
1385 expect(removed).toEqual("no-lifecycle");
1386
1387 yield* stack.destroy();
1388 yield* assertBucketDeleted(bucket.bucketName);
1389 }),
1390 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1391);
1392
1393test.provider(
1394 "ownership controls and website hosting serves index.html",
1395 (stack) =>
1396 Effect.gen(function* () {
1397 yield* stack.destroy();
1398
1399 const name = "alchemy-test-bucket-website";
1400 const bucket = yield* stack.deploy(
1401 Bucket("WebsiteBucket", {
1402 bucketName: name,
1403 objectOwnership: "BucketOwnerPreferred",
1404 // The website endpoint serves anonymously, so the bucket must allow
1405 // a public-read policy: PAB flags all off + a public GetObject
1406 // statement. (publicAccessBlock syncs before policy in reconcile.)
1407 publicAccessBlock: {
1408 blockPublicAcls: false,
1409 ignorePublicAcls: false,
1410 blockPublicPolicy: false,
1411 restrictPublicBuckets: false,
1412 },
1413 policy: [
1414 {
1415 Sid: "PublicReadGetObject",
1416 Effect: "Allow",
1417 Principal: { AWS: "*" },
1418 Action: ["s3:GetObject"],
1419 Resource: [`arn:aws:s3:::${name}/*`],
1420 },
1421 ],
1422 website: {
1423 indexDocument: { suffix: "index.html" },
1424 errorDocument: { key: "error.html" },
1425 },
1426 forceDestroy: true,
1427 }),
1428 );
1429
1430 const own = yield* S3.getBucketOwnershipControls({
1431 Bucket: bucket.bucketName,
1432 });
1433 expect(own.OwnershipControls?.Rules?.[0]?.ObjectOwnership).toEqual(
1434 "BucketOwnerPreferred",
1435 );
1436
1437 const web = yield* S3.getBucketWebsite({ Bucket: bucket.bucketName });
1438 expect(web.IndexDocument?.Suffix).toEqual("index.html");
1439 expect(web.ErrorDocument?.Key).toEqual("error.html");
1440
1441 // Prove the website endpoint actually serves: upload an index document
1442 // and GET it anonymously over HTTP (marker-anchored, with propagation
1443 // retry — fresh website endpoints can take a few seconds to serve).
1444 const marker = "alchemy-s3-website-test-marker";
1445 yield* S3.putObject({
1446 Bucket: bucket.bucketName,
1447 Key: "index.html",
1448 Body: `<html><body>${marker}</body></html>`,
1449 ContentType: "text/html",
1450 });
1451
1452 const websiteUrl = `http://${name}.s3-website-${bucket.region}.amazonaws.com/`;
1453 const body = yield* HttpClient.get(websiteUrl).pipe(
1454 Effect.flatMap((response) =>
1455 response.status === 200
1456 ? response.text
1457 : Effect.fail(
1458 new Error(`website endpoint returned ${response.status}`),
1459 ),
1460 ),
1461 Effect.retry({
1462 schedule: Schedule.max([
1463 Schedule.exponential(1000),
1464 Schedule.recurs(8),
1465 ]),
1466 }),
1467 );
1468 expect(body).toContain(marker);
1469
1470 // In-place update of the index document; website config is in-place
1471 // updatable. (Omitting the prop leaves the config untouched — to clear it
1472 // a user removes the resource; we never silently drop unmanaged config.)
1473 yield* stack.deploy(
1474 Bucket("WebsiteBucket", {
1475 bucketName: name,
1476 objectOwnership: "BucketOwnerPreferred",
1477 publicAccessBlock: {
1478 blockPublicAcls: false,
1479 ignorePublicAcls: false,
1480 blockPublicPolicy: false,
1481 restrictPublicBuckets: false,
1482 },
1483 policy: [
1484 {
1485 Sid: "PublicReadGetObject",
1486 Effect: "Allow",
1487 Principal: { AWS: "*" },
1488 Action: ["s3:GetObject"],
1489 Resource: [`arn:aws:s3:::${name}/*`],
1490 },
1491 ],
1492 website: {
1493 indexDocument: { suffix: "home.html" },
1494 errorDocument: { key: "error.html" },
1495 },
1496 forceDestroy: true,
1497 }),
1498 );
1499
1500 const web2 = yield* S3.getBucketWebsite({ Bucket: bucket.bucketName });
1501 expect(web2.IndexDocument?.Suffix).toEqual("home.html");
1502
1503 yield* stack.destroy();
1504 yield* assertBucketDeleted(bucket.bucketName);
1505 }),
1506 { tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
1507);
1508
1509// Server-access logging needs a same-stack target bucket that grants the S3
1510// logging service principal permission to deliver logs (the modern, ACL-free
1511// grant — works with the BucketOwnerEnforced default).
1512test.provider(
1513 "server access logging set and update prefix",
1514 (stack) =>
1515 Effect.gen(function* () {
1516 yield* stack.destroy();
1517
1518 const target = "alchemy-test-bucket-logging-target";
1519 const source = "alchemy-test-bucket-logging-src";
1520
1521 const deployWith = (targetPrefix: string) =>
1522 stack.deploy(
1523 Effect.gen(function* () {
1524 const targetBucket = yield* Bucket("LogTargetBucket", {
1525 bucketName: target,
1526 policy: [
1527 {
1528 Sid: "S3ServerAccessLogsPolicy",
1529 Effect: "Allow",
1530 Principal: { Service: "logging.s3.amazonaws.com" },
1531 Action: ["s3:PutObject"],
1532 Resource: [`arn:aws:s3:::${target}/*`],
1533 Condition: {
1534 ArnLike: { "aws:SourceArn": `arn:aws:s3:::${source}` },
1535 },
1536 },
1537 ],
1538 forceDestroy: true,
1539 });
1540 const sourceBucket = yield* Bucket("LogSourceBucket", {
1541 bucketName: source,
1542 logging: {
1543 // Output reference so the target (and its delivery policy)
1544 // reconciles before the source's putBucketLogging.
1545 targetBucket: targetBucket.bucketName,
1546 targetPrefix,
1547 },
1548 forceDestroy: true,
1549 });
1550 return { sourceBucket, targetBucket };
1551 }),
1552 );
1553
1554 const buckets = yield* deployWith("logs/");
1555
1556 const l1 = yield* S3.getBucketLogging({
1557 Bucket: buckets.sourceBucket.bucketName,
1558 });
1559 expect(l1.LoggingEnabled?.TargetBucket).toEqual(target);
1560 expect(l1.LoggingEnabled?.TargetPrefix).toEqual("logs/");
1561
1562 // In-place update of the log prefix.
1563 yield* deployWith("access/");
1564
1565 const l2 = yield* S3.getBucketLogging({
1566 Bucket: buckets.sourceBucket.bucketName,
1567 });
1568 expect(l2.LoggingEnabled?.TargetPrefix).toEqual("access/");
1569
1570 yield* stack.destroy();
1571 yield* assertBucketDeleted(buckets.sourceBucket.bucketName);
1572 yield* assertBucketDeleted(buckets.targetBucket.bucketName);
1573 }),
1574 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1575);
1576
1577test.provider(
1578 "transfer acceleration and request payment",
1579 (stack) =>
1580 Effect.gen(function* () {
1581 yield* stack.destroy();
1582
1583 const name = "alchemy-test-bucket-accel-pay";
1584 const bucket = yield* stack.deploy(
1585 Bucket("AccelPayBucket", {
1586 bucketName: name,
1587 transferAcceleration: "Enabled",
1588 requestPayer: "Requester",
1589 forceDestroy: true,
1590 }),
1591 );
1592
1593 const a1 = yield* S3.getBucketAccelerateConfiguration({
1594 Bucket: bucket.bucketName,
1595 });
1596 expect(a1.Status).toEqual("Enabled");
1597
1598 const r1 = yield* S3.getBucketRequestPayment({
1599 Bucket: bucket.bucketName,
1600 });
1601 expect(r1.Payer).toEqual("Requester");
1602
1603 yield* stack.deploy(
1604 Bucket("AccelPayBucket", {
1605 bucketName: name,
1606 transferAcceleration: "Suspended",
1607 requestPayer: "BucketOwner",
1608 forceDestroy: true,
1609 }),
1610 );
1611
1612 const a2 = yield* S3.getBucketAccelerateConfiguration({
1613 Bucket: bucket.bucketName,
1614 });
1615 expect(a2.Status).toEqual("Suspended");
1616
1617 const r2 = yield* S3.getBucketRequestPayment({
1618 Bucket: bucket.bucketName,
1619 });
1620 expect(r2.Payer).toEqual("BucketOwner");
1621
1622 yield* stack.destroy();
1623 yield* assertBucketDeleted(bucket.bucketName);
1624 }),
1625 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1626);
1627
1628test.provider(
1629 "object lock default retention",
1630 (stack) =>
1631 Effect.gen(function* () {
1632 yield* stack.destroy();
1633
1634 const name = "alchemy-test-bucket-objlock-retention";
1635 const bucket = yield* stack.deploy(
1636 Bucket("ObjLockRetentionBucket", {
1637 bucketName: name,
1638 objectLockEnabled: true,
1639 objectLockConfiguration: { mode: "GOVERNANCE", days: "1 day" },
1640 forceDestroy: true,
1641 }),
1642 );
1643
1644 const cfg = yield* S3.getObjectLockConfiguration({
1645 Bucket: bucket.bucketName,
1646 });
1647 expect(cfg.ObjectLockConfiguration?.Rule?.DefaultRetention?.Mode).toEqual(
1648 "GOVERNANCE",
1649 );
1650 expect(cfg.ObjectLockConfiguration?.Rule?.DefaultRetention?.Days).toEqual(
1651 1,
1652 );
1653
1654 yield* stack.destroy();
1655 yield* assertBucketDeleted(bucket.bucketName);
1656 }),
1657 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1658);
1659
1660test.provider(
1661 "intelligent tiering add and remove id",
1662 (stack) =>
1663 Effect.gen(function* () {
1664 yield* stack.destroy();
1665
1666 const name = "alchemy-test-bucket-int-tiering";
1667 const bucket = yield* stack.deploy(
1668 Bucket("IntTieringBucket", {
1669 bucketName: name,
1670 intelligentTiering: [
1671 {
1672 Id: "archive",
1673 Status: "Enabled",
1674 Tierings: [{ Days: 90, AccessTier: "ARCHIVE_ACCESS" }],
1675 },
1676 ],
1677 forceDestroy: true,
1678 }),
1679 );
1680
1681 const t1 = yield* S3.getBucketIntelligentTieringConfiguration({
1682 Bucket: bucket.bucketName,
1683 Id: "archive",
1684 });
1685 expect(t1.IntelligentTieringConfiguration?.Status).toEqual("Enabled");
1686
1687 yield* stack.deploy(
1688 Bucket("IntTieringBucket", {
1689 bucketName: name,
1690 intelligentTiering: [],
1691 forceDestroy: true,
1692 }),
1693 );
1694
1695 const removed = yield* S3.getBucketIntelligentTieringConfiguration({
1696 Bucket: bucket.bucketName,
1697 Id: "archive",
1698 }).pipe(
1699 Effect.map(() => "has-config" as const),
1700 Effect.catchTag("NoSuchConfiguration", () =>
1701 Effect.succeed("no-config" as const),
1702 ),
1703 );
1704 expect(removed).toEqual("no-config");
1705
1706 yield* stack.destroy();
1707 yield* assertBucketDeleted(bucket.bucketName);
1708 }),
1709 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1710);
1711
1712test.provider(
1713 "explicit bucket policy prop",
1714 (stack) =>
1715 Effect.gen(function* () {
1716 yield* stack.destroy();
1717
1718 const name = "alchemy-test-bucket-policy-prop";
1719 const bucketArn = `arn:aws:s3:::${name}`;
1720 const bucket = yield* stack.deploy(
1721 Bucket("PolicyPropBucket", {
1722 bucketName: name,
1723 policy: [
1724 {
1725 Sid: "AllowCloudFront",
1726 Effect: "Allow",
1727 Principal: { Service: "cloudfront.amazonaws.com" },
1728 Action: ["s3:GetObject"],
1729 Resource: [`${bucketArn}/*`],
1730 },
1731 ],
1732 forceDestroy: true,
1733 }),
1734 );
1735
1736 const policy = yield* S3.getBucketPolicy({
1737 Bucket: bucket.bucketName,
1738 }).pipe(Effect.map((r) => JSON.parse(r.Policy!)));
1739 expect(policy.Statement[0].Sid).toEqual("AllowCloudFront");
1740
1741 yield* stack.destroy();
1742 yield* assertBucketDeleted(bucket.bucketName);
1743 }),
1744 { tags: ["provider:aws", "provider:aws:s3", "live"] },
1745);
1746
1747// Replication needs an IAM role S3 can assume + a versioned destination bucket.
1748// The test provisions all of it (role + dest bucket) so it is self-contained.
1749test.provider(
1750 "replication configuration",
1751 (stack) =>
1752 Effect.gen(function* () {
1753 yield* stack.destroy();
1754
1755 const dest = "alchemy-test-bucket-replication-dest";
1756 const src = "alchemy-test-bucket-replication-src";
1757
1758 const buckets = yield* stack.deploy(
1759 Effect.gen(function* () {
1760 const destBucket = yield* Bucket("ReplDestBucket", {
1761 bucketName: dest,
1762 versioning: "Enabled",
1763 forceDestroy: true,
1764 });
1765 const replRole = yield* Role("ReplRole", {
1766 roleName: "alchemy-test-s3-replication-role",
1767 assumeRolePolicyDocument: {
1768 Version: "2012-10-17",
1769 Statement: [
1770 {
1771 Effect: "Allow",
1772 Principal: { Service: "s3.amazonaws.com" },
1773 Action: ["sts:AssumeRole"],
1774 },
1775 ],
1776 },
1777 inlinePolicies: {
1778 Replication: {
1779 Version: "2012-10-17",
1780 Statement: [
1781 {
1782 Effect: "Allow",
1783 Action: ["s3:GetReplicationConfiguration", "s3:ListBucket"],
1784 Resource: [`arn:aws:s3:::${src}`],
1785 },
1786 {
1787 Effect: "Allow",
1788 Action: [
1789 "s3:GetObjectVersionForReplication",
1790 "s3:GetObjectVersionAcl",
1791 "s3:GetObjectVersionTagging",
1792 ],
1793 Resource: [`arn:aws:s3:::${src}/*`],
1794 },
1795 {
1796 Effect: "Allow",
1797 Action: [
1798 "s3:ReplicateObject",
1799 "s3:ReplicateDelete",
1800 "s3:ReplicateTags",
1801 ],
1802 Resource: [`arn:aws:s3:::${dest}/*`],
1803 },
1804 ],
1805 },
1806 },
1807 });
1808 const srcBucket = yield* Bucket("ReplSrcBucket", {
1809 bucketName: src,
1810 versioning: "Enabled",
1811 replication: {
1812 role: replRole.roleArn,
1813 rules: [
1814 {
1815 ID: "replicate-all",
1816 Status: "Enabled",
1817 Priority: 1,
1818 Filter: {},
1819 DeleteMarkerReplication: { Status: "Disabled" },
1820 Destination: { Bucket: `arn:aws:s3:::${dest}` },
1821 },
1822 ],
1823 },
1824 forceDestroy: true,
1825 });
1826 return { srcBucket, destBucket, roleArn: replRole.roleArn };
1827 }),
1828 );
1829
1830 const repl = yield* S3.getBucketReplication({
1831 Bucket: buckets.srcBucket.bucketName,
1832 });
1833 expect(repl.ReplicationConfiguration?.Role).toEqual(buckets.roleArn);
1834 expect(repl.ReplicationConfiguration?.Rules).toHaveLength(1);
1835
1836 yield* stack.destroy();
1837 yield* assertBucketDeleted(buckets.srcBucket.bucketName);
1838 yield* assertBucketDeleted(buckets.destBucket.bucketName);
1839 }),
1840 {
1841 tags: ["provider:aws", "provider:aws:iam", "provider:aws:s3", "live"],
1842 timeout: 180_000,
1843 },
1844);
1845
1846// Idempotent out-of-band delete for the adoption test's deterministically
1847// named bucket. Tolerates the bucket never existing / already deleted
1848// (typed NoSuchBucket) and retries transient failures a bounded number of
1849// times. The bucket is always empty in that test, so deleteBucket suffices.
1850const deleteBucketIfExists = Effect.fn(function* (bucketName: string) {
1851 yield* S3.deleteBucket({ Bucket: bucketName }).pipe(
1852 Effect.catchTag("NoSuchBucket", () => Effect.void),
1853 Effect.retry({
1854 schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(5)]),
1855 }),
1856 );
1857});
1858
1859class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
1860
1861const assertBucketDeleted = Effect.fn(function* (bucketName: string) {
1862 yield* S3.headBucket({ Bucket: bucketName }).pipe(
1863 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
1864 Effect.retry({
1865 while: (e) => e._tag === "BucketStillExists",
1866 schedule: Schedule.max([Schedule.exponential(100), Schedule.recurs(10)]),
1867 }),
1868 Effect.catchTag("NotFound", () => Effect.void),
1869 Effect.catch(() => Effect.void),
1870 );
1871});
1872
1873// ── destructive deletes require explicit opt-in ────────────────────────
1874//
1875// DATA-PROTECTION INVARIANT: `delete` may remove the bucket, but it must
1876// NEVER destroy the bucket's CONTENTS unless the user opted in on the
1877// resource (`forceDestroy`) or an operator ran `alchemy unsafe nuke`
1878// (which passes `force: true`).
1879//
1880// S3 refuses to delete a non-empty bucket (`BucketNotEmpty`). That refusal
1881// is the last line of defense for production data, and emptying the bucket
1882// first silently converts a routine teardown into irreversible data loss
1883// (see https://github.com/alchemy-run/alchemy/issues/1248 for the R2
1884// incident this guards against here too).
1885//
1886// A live test can only observe that a bucket survived, not that no
1887// destructive request was ever issued. These run the REAL provider
1888// `delete` against a recording transport and assert on the wire traffic.
1889
1890type Recorded = { method: string; url: string };
1891
1892/** Fetch transport that records every request and answers from `respond`. */
1893const recordingTransport = (respond: (call: Recorded) => Response) => {
1894 const calls: Recorded[] = [];
1895 const fetch = async (
1896 input: string | URL | Request,
1897 init?: RequestInit,
1898 ): Promise<Response> => {
1899 calls.push({
1900 method: (input instanceof Request ? input.method : init?.method) ?? "GET",
1901 url: input instanceof Request ? input.url : String(input),
1902 });
1903 return respond(calls[calls.length - 1]!);
1904 };
1905 return {
1906 calls,
1907 layer: FetchHttpClient.layer.pipe(
1908 Layer.provide(
1909 Layer.succeed(FetchHttpClient.Fetch, fetch as typeof globalThis.fetch),
1910 ),
1911 ),
1912 };
1913};
1914
1915const TEST_REGION = "us-east-1";
1916const INSTANCE_ID = "0123456789abcdef0123456789abcdef";
1917
1918const testStack: Omit<StackSpec, "output"> = {
1919 name: "my-stack",
1920 stage: "dev",
1921 resources: {},
1922 bindings: {},
1923 actions: {},
1924};
1925
1926// Built with distilled's own helper: the signer reads credentials through
1927// distilled's copy of `effect`, whose `Redacted` values a `Redacted.make`
1928// from this package's copy cannot unwrap.
1929const testCredentials = fromCredentials(
1930 { accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "test-secret-key" },
1931 TEST_REGION,
1932);
1933
1934const stubbedEnv = (transport: Layer.Layer<HttpClient.HttpClient>) =>
1935 Layer.mergeAll(
1936 Layer.effect(
1937 AWSEnvironment,
1938 Effect.map(Credentials, (credentials) =>
1939 Effect.succeed({
1940 accountId: "123456789012",
1941 region: TEST_REGION,
1942 credentials,
1943 } as never),
1944 ),
1945 ).pipe(Layer.provide(testCredentials)),
1946 testCredentials,
1947 Layer.succeed(Region, Effect.succeed(TEST_REGION)),
1948 Layer.succeed(Stack, testStack),
1949 Layer.succeed(Stage, testStack.stage),
1950 Layer.succeed(InstanceId, INSTANCE_ID),
1951 Layer.succeed(AlchemyContext, {
1952 dotAlchemy: "/tmp/.alchemy-test",
1953 dev: false,
1954 adopt: false,
1955 }),
1956 Layer.sync(ArtifactStore, createArtifactStore),
1957 inMemoryState(),
1958 NodeServices.layer,
1959 ).pipe(Layer.provideMerge(transport));
1960
1961const stubbedOutput = {
1962 bucketName: "my-bucket",
1963 bucketArn: "arn:aws:s3:::my-bucket",
1964 region: TEST_REGION,
1965 bucketDomainName: "my-bucket.s3.amazonaws.com",
1966 bucketRegionalDomainName: "my-bucket.s3.us-east-1.amazonaws.com",
1967 hostedZoneId: "Z3AQBSTGFYJSTF",
1968 tags: {},
1969};
1970
1971/** `POST /?delete` — S3's bulk object/version delete. */
1972const objectDeletes = (calls: Recorded[]) =>
1973 calls.filter((c) => c.method === "POST" && /[?&]delete/.test(c.url));
1974
1975/** `GET /?versions` — enumerating what to wipe. */
1976const versionListings = (calls: Recorded[]) =>
1977 calls.filter((c) => c.method === "GET" && /[?&]versions/.test(c.url));
1978
1979const bucketDeletes = (calls: Recorded[]) =>
1980 calls.filter((c) => c.method === "DELETE" && !/[?&]/.test(c.url));
1981
1982/** One object version, so the empty path has something to delete. */
1983const stubResponse = (call: Recorded) =>
1984 /[?&]versions/.test(call.url)
1985 ? new Response(
1986 `<?xml version="1.0" encoding="UTF-8"?>
1987 <ListVersionsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1988 <Name>my-bucket</Name>
1989 <IsTruncated>false</IsTruncated>
1990 <Version>
1991 <Key>precious.txt</Key>
1992 <VersionId>null</VersionId>
1993 </Version>
1994 </ListVersionsResult>`,
1995 { status: 200, headers: { "content-type": "application/xml" } },
1996 )
1997 : new Response("", {
1998 status: 204,
1999 headers: { "content-type": "application/xml" },
2000 });
2001
2002/** Run the real provider delete; return every request it made. */
2003const recordDelete = (
2004 props: { forceDestroy?: boolean },
2005 options?: { force?: boolean },
2006) =>
2007 Effect.gen(function* () {
2008 const transport = recordingTransport(stubResponse);
2009 yield* Effect.gen(function* () {
2010 const provider = yield* Provider.Provider<Bucket>("AWS.S3.Bucket");
2011 yield* provider.delete({
2012 id: "Bucket",
2013 fqn: "Bucket",
2014 instanceId: INSTANCE_ID,
2015 olds: props,
2016 output: stubbedOutput as never,
2017 bindings: [] as never,
2018 session: {
2019 emit: () => Effect.void,
2020 done: () => Effect.void,
2021 note: () => Effect.void,
2022 },
2023 force: options?.force,
2024 });
2025 }).pipe(
2026 Effect.provide(BucketProvider()),
2027 Effect.provide(stubbedEnv(transport.layer)),
2028 );
2029 return transport.calls;
2030 });
2031
2032describe(
2033 "destructive delete requires explicit opt-in",
2034 { tags: ["unit", "provider:aws", "provider:aws:s3", "local"] },
2035 () => {
2036 it.effect("no forceDestroy never empties the bucket", () =>
2037 Effect.gen(function* () {
2038 const calls = yield* recordDelete({});
2039
2040 expect(objectDeletes(calls)).toEqual([]);
2041 expect(versionListings(calls)).toEqual([]);
2042 // The bucket delete itself is still attempted — S3 answers
2043 // `BucketNotEmpty`, which is the protection.
2044 expect(bucketDeletes(calls).length).toBeGreaterThan(0);
2045 }),
2046 );
2047
2048 it.effect("forceDestroy empties the bucket first", () =>
2049 Effect.gen(function* () {
2050 const calls = yield* recordDelete({ forceDestroy: true });
2051
2052 expect(objectDeletes(calls).length).toBeGreaterThan(0);
2053 expect(bucketDeletes(calls).length).toBeGreaterThan(0);
2054 }),
2055 );
2056
2057 // Nuke enumerates buckets from the cloud, so `olds` carries Attributes and
2058 // never has `forceDestroy` — the operator's confirmation IS the flag.
2059 it.effect("nuke's force empties without the prop", () =>
2060 Effect.gen(function* () {
2061 const calls = yield* recordDelete({}, { force: true });
2062
2063 expect(objectDeletes(calls).length).toBeGreaterThan(0);
2064 }),
2065 );
2066 },
2067);