t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/AWS/S3/Bucket.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

2067 lines
67 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { AlchemyContext } from "@/AlchemyContext.ts";
import { ArtifactStore, createArtifactStore } from "@/Artifacts.ts";
import * as AWS from "@/AWS";
import { AWSEnvironment } from "@/AWS/Environment.ts";
import { Role } from "@/AWS/IAM";
import { Bucket } from "@/AWS/S3";
import { BucketProvider } from "@/AWS/S3/Bucket.ts";
import { InstanceId } from "@/InstanceId.ts";
import * as Provider from "@/Provider";
import { Stack, type StackSpec } from "@/Stack.ts";
import { Stage } from "@/Stage.ts";
import { State } from "@/State";
import * as Test from "@/Test/Alchemy";
import { inMemoryState } from "@/State";
import { Credentials, fromCredentials } from "@distilled.cloud/aws/Credentials";
import { Region } from "@distilled.cloud/aws/Region";
import * as KMS from "@distilled.cloud/aws/kms";
import * as S3 from "@distilled.cloud/aws/s3";
import { NodeServices } from "@effect/platform-node";
import { describe, expect, it } from "alchemy-test";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import * as Result from "effect/Result";
import * as Schedule from "effect/Schedule";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as HttpClient from "effect/http/HttpClient";
const { test } = Test.make({ providers: AWS.providers() });
test.provider(
"PR1585 discovers an owned bucket without ListBucket and rejects denied location reads",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(Bucket("OwnershipBucket", {}));
const { accountId } = yield* AWSEnvironment.current;
const definition = (value: string) =>
Bucket("OwnershipBucket", { tags: { revision: value } });
const discovery = Effect.gen(function* () {
yield* definition("updated");
return yield* Bucket("DiscoveryProbe", {
bucketName: bucket.bucketName,
});
});
const deny = (action: string) =>
S3.putBucketPolicy({
Bucket: bucket.bucketName,
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Deny",
Principal: "*",
Action: action,
Resource: bucket.bucketArn,
},
],
}),
});
const restorePolicy = S3.deleteBucketPolicy({
Bucket: bucket.bucketName,
});
yield* Effect.gen(function* () {
yield* deny("s3:ListBucket");
const listDenied = yield* S3.listObjectsV2({
Bucket: bucket.bucketName,
}).pipe(
Effect.as(false),
Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
Effect.repeat({
until: Boolean,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
expect(listDenied).toBe(true);
yield* S3.getBucketLocation({
Bucket: bucket.bucketName,
ExpectedBucketOwner: accountId,
});
// The probe is planned, not applied: the original resource retains ownership.
const plan = yield* stack.plan(discovery);
expect(plan.resources.DiscoveryProbe?.action).toBe("adopted");
yield* stack.deploy(definition("updated"));
expect(
(yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
).toContainEqual({ Key: "revision", Value: "updated" });
const wrongOwner = `${accountId.slice(0, -1)}${accountId.endsWith("0") ? "1" : "0"}`;
const mismatch = yield* S3.getBucketLocation({
Bucket: bucket.bucketName,
ExpectedBucketOwner: wrongOwner,
}).pipe(Effect.flip);
expect(mismatch._tag).toBe("AccessDeniedException");
yield* deny("s3:GetBucketLocation");
const locationDenied = yield* S3.getBucketLocation({
Bucket: bucket.bucketName,
ExpectedBucketOwner: accountId,
}).pipe(
Effect.as(false),
Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
Effect.repeat({
until: Boolean,
schedule: Schedule.spaced("2 seconds"),
times: 10,
}),
);
expect(locationDenied).toBe(true);
// HeadBucket remains allowed; treating this rejection as absence would plan a create.
yield* S3.listObjectsV2({ Bucket: bucket.bucketName }).pipe(
Effect.retry({
while: (error) => error._tag === "AccessDeniedException",
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
const result = yield* stack.plan(discovery).pipe(
Effect.result,
Effect.repeat({
until: Result.isFailure,
schedule: Schedule.spaced("2 seconds"),
times: 10,
}),
);
expect(Result.isFailure(result)).toBe(true);
if (Result.isFailure(result)) {
expect(result.failure._tag).toBe("AccessDeniedException");
}
}).pipe(Effect.ensuring(restorePolicy.pipe(Effect.orDie)));
yield* S3.getBucketLocation({
Bucket: bucket.bucketName,
ExpectedBucketOwner: accountId,
}).pipe(
Effect.retry({
while: (error) => error._tag === "AccessDeniedException",
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
const absent = yield* S3.getBucketLocation({
Bucket: bucket.bucketName,
}).pipe(
Effect.as(false),
Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
Effect.repeat({
until: Boolean,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
expect(absent).toBe(true);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
);
for (const aspect of ["tagging", "encryption"] as const) {
test.provider(
`PR1586 preserves ${aspect} when its read is denied but writes are allowed`,
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const initialProps = {
tags: { revision: "before" },
encryption: { sseAlgorithm: "AES256" as const },
};
const bucket = yield* stack.deploy(
Bucket("ReadFailureBucket", initialProps),
);
const tagging = yield* S3.getBucketTagging({
Bucket: bucket.bucketName,
});
const encryption = yield* S3.getBucketEncryption({
Bucket: bucket.bucketName,
});
const restorePolicy = S3.deleteBucketPolicy({
Bucket: bucket.bucketName,
});
const read = Effect.gen(function* () {
if (aspect === "tagging") {
yield* S3.getBucketTagging({ Bucket: bucket.bucketName });
} else {
yield* S3.getBucketEncryption({ Bucket: bucket.bucketName });
}
});
const desired = Bucket("ReadFailureBucket", {
tags: { revision: "after" },
encryption: {
sseAlgorithm: aspect === "encryption" ? "aws:kms" : "AES256",
},
});
yield* Effect.gen(function* () {
yield* S3.putBucketPolicy({
Bucket: bucket.bucketName,
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Deny",
Principal: "*",
Action:
aspect === "tagging"
? "s3:GetBucketTagging"
: "s3:GetEncryptionConfiguration",
Resource: bucket.bucketArn,
},
],
}),
});
const denied = yield* read.pipe(
Effect.as(false),
Effect.catchTag("AccessDeniedException", () =>
Effect.succeed(true),
),
Effect.repeat({
until: Boolean,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
expect(denied).toBe(true);
// Reapply the observed configuration to prove the write permission remains usable.
if (aspect === "tagging") {
yield* S3.putBucketTagging({
Bucket: bucket.bucketName,
Tagging: { TagSet: tagging.TagSet },
});
} else {
yield* S3.putBucketEncryption({
Bucket: bucket.bucketName,
ServerSideEncryptionConfiguration:
encryption.ServerSideEncryptionConfiguration!,
});
}
const planningFailure = yield* stack.plan(desired).pipe(Effect.flip);
expect(planningFailure._tag).toBe("AccessDeniedException");
const failure = yield* stack.deploy(desired).pipe(Effect.flip);
expect(failure._tag).toBe("AccessDeniedException");
}).pipe(Effect.ensuring(restorePolicy.pipe(Effect.orDie)));
yield* read.pipe(
Effect.retry({
while: (error) => error._tag === "AccessDeniedException",
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
if (aspect === "tagging") {
expect(
(yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
).toEqual(tagging.TagSet);
} else {
expect(
(yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
.ServerSideEncryptionConfiguration,
).toEqual(encryption.ServerSideEncryptionConfiguration);
}
yield* stack.deploy(desired);
if (aspect === "tagging") {
expect(
(yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
).toContainEqual({ Key: "revision", Value: "after" });
} else {
expect(
(yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
.ServerSideEncryptionConfiguration?.Rules[0]
?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm,
).toBe("aws:kms");
}
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
);
}
test.provider(
"PR1586 initializes tags after a real NoSuchTagSet response",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(Bucket("UntaggedBucket", {}));
const absent = yield* S3.getBucketTagging({
Bucket: bucket.bucketName,
}).pipe(Effect.flip);
expect(absent._tag).toBe("NoSuchTagSet");
yield* stack.deploy(
Bucket("UntaggedBucket", { tags: { initialized: "yes" } }),
);
expect(
(yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
).toEqual([{ Key: "initialized", Value: "yes" }]);
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
);
test.provider(
"PR1587 skips encryption writes for a decoded KMS ARN and still updates keys",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const definition = (
revision: string,
key: "first" | "second" | undefined,
bucketKeyEnabled = false,
policy?: AWS.IAM.PolicyStatement[],
) =>
Effect.gen(function* () {
const first = yield* AWS.KMS.Key("FirstKey", {
deletionWindow: "7 days",
});
const second = yield* AWS.KMS.Key("SecondKey", {
deletionWindow: "7 days",
});
const bucket = yield* Bucket("KmsIdentityBucket", {
tags: { revision },
policy,
encryption:
key === undefined
? undefined
: {
sseAlgorithm: "aws:kms",
kmsMasterKeyId:
key === "first" ? first.keyArn : second.keyArn,
bucketKeyEnabled,
},
});
return { bucket, first, second };
});
const initial = yield* stack.deploy(definition("before", "first"));
const { bucket, first, second } = initial;
const observed = yield* S3.getBucketEncryption({
Bucket: bucket.bucketName,
});
const decoded =
observed.ServerSideEncryptionConfiguration!.Rules[0]!
.ApplyServerSideEncryptionByDefault!.KMSMasterKeyID;
expect(Redacted.isRedacted(decoded)).toBe(true);
expect(
Redacted.isRedacted(decoded) ? Redacted.value(decoded) : decoded,
).toBe(first.keyArn);
const probeWrite = S3.putBucketEncryption({
Bucket: bucket.bucketName,
ServerSideEncryptionConfiguration:
observed.ServerSideEncryptionConfiguration!,
}).pipe(
Effect.as(false),
Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
);
const deny: AWS.IAM.PolicyStatement[] = [
{
Effect: "Deny",
Principal: { AWS: "*" },
Action: ["s3:PutEncryptionConfiguration"],
Resource: bucket.bucketArn,
},
];
yield* Effect.gen(function* () {
yield* S3.putBucketPolicy({
Bucket: bucket.bucketName,
Policy: JSON.stringify({ Version: "2012-10-17", Statement: deny }),
});
expect(
yield* probeWrite.pipe(
Effect.repeat({
until: Boolean,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
),
).toBe(true);
const plan = yield* stack.plan(
definition("after", "first", false, deny),
);
expect(plan.resources.KmsIdentityBucket?.action).toBe("update");
// Tags force reconcile; an encryption PUT would fail under the proven deny.
yield* stack.deploy(definition("after", "first", false, deny));
expect(
(yield* S3.getBucketTagging({ Bucket: bucket.bucketName })).TagSet,
).toContainEqual({ Key: "revision", Value: "after" });
expect(yield* probeWrite).toBe(true);
}).pipe(
Effect.ensuring(
S3.deleteBucketPolicy({ Bucket: bucket.bucketName }).pipe(
Effect.orDie,
),
),
);
// Wait for removal using the original configuration, never the desired next key.
yield* S3.putBucketEncryption({
Bucket: bucket.bucketName,
ServerSideEncryptionConfiguration:
observed.ServerSideEncryptionConfiguration!,
}).pipe(
Effect.retry({
while: (error) => error._tag === "AccessDeniedException",
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
yield* stack.deploy(definition("different-key", "second"));
const changed = (yield* S3.getBucketEncryption({
Bucket: bucket.bucketName,
})).ServerSideEncryptionConfiguration!.Rules[0]!
.ApplyServerSideEncryptionByDefault!.KMSMasterKeyID;
expect(
Redacted.isRedacted(changed) ? Redacted.value(changed) : changed,
).toBe(second.keyArn);
yield* stack.deploy(definition("bucket-key", "second", true));
expect(
(yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
.ServerSideEncryptionConfiguration?.Rules[0]?.BucketKeyEnabled,
).toBe(true);
const defaultsProgram = definition("bucket-key", undefined);
yield* stack.deploy(defaultsProgram);
const defaults = (yield* S3.getBucketEncryption({
Bucket: bucket.bucketName,
})).ServerSideEncryptionConfiguration!.Rules[0]!;
expect(defaults.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
"AES256",
);
expect(
defaults.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID,
).toBeUndefined();
expect(defaults.BucketKeyEnabled ?? false).toBe(false);
expect(defaults.BlockedEncryptionTypes?.EncryptionType).toEqual(["NONE"]);
expect(
(yield* stack.plan(defaultsProgram)).resources.KmsIdentityBucket
?.action,
).toBe("noop");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
for (const key of [first, second]) {
expect(
(yield* KMS.describeKey({ KeyId: key.keyId })).KeyMetadata?.KeyState,
).toBe("PendingDeletion");
}
}),
{
tags: ["provider:aws", "provider:aws:kms", "provider:aws:s3", "live"],
timeout: 120_000,
},
);
for (const blocked of ["SSE-C", "NONE"] as const) {
test.provider(
`repairs external ${blocked} encryption settings with unchanged default inputs`,
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const desired = Bucket("EncryptionBlocksBucket", {});
const bucket = yield* stack.deploy(desired);
expect(
(yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }))
.ServerSideEncryptionConfiguration?.Rules[0]?.BlockedEncryptionTypes
?.EncryptionType,
).toEqual(["NONE"]);
yield* S3.putBucketEncryption({
Bucket: bucket.bucketName,
ServerSideEncryptionConfiguration: {
Rules: [
{
ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "aws:kms" },
BlockedEncryptionTypes: { EncryptionType: [blocked] },
},
],
},
});
const before = (yield* S3.getBucketEncryption({
Bucket: bucket.bucketName,
})).ServerSideEncryptionConfiguration!.Rules[0]!;
expect(before.BlockedEncryptionTypes?.EncryptionType).toEqual([
blocked,
]);
expect(before.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
"aws:kms",
);
const plan = yield* stack.plan(desired);
expect(plan.resources.EncryptionBlocksBucket?.action).toBe("update");
yield* stack.deploy(desired);
const after = (yield* S3.getBucketEncryption({
Bucket: bucket.bucketName,
})).ServerSideEncryptionConfiguration!.Rules[0]!;
expect(after.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
"AES256",
);
expect(
after.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID,
).toBeUndefined();
expect(after.BucketKeyEnabled ?? false).toBe(false);
expect(after.BlockedEncryptionTypes?.EncryptionType).toEqual(["NONE"]);
expect(
(yield* stack.plan(desired)).resources.EncryptionBlocksBucket?.action,
).toBe("noop");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
);
}
test.provider(
"manages encryption blocks and restores defaults on property removal",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const program = (
blockedEncryptionTypes: AWS.S3.BucketEncryption["blockedEncryptionTypes"],
revision: string,
policy?: AWS.IAM.PolicyStatement[],
) =>
Bucket("ManagedEncryptionBlocks", {
encryption: { sseAlgorithm: "AES256", blockedEncryptionTypes },
tags: { revision },
policy,
});
const bucket = yield* stack.deploy(program([], "managed"));
const readRule = S3.getBucketEncryption({
Bucket: bucket.bucketName,
}).pipe(
Effect.map(
(result) => result.ServerSideEncryptionConfiguration!.Rules[0]!,
),
);
expect((yield* readRule).BlockedEncryptionTypes?.EncryptionType).toEqual([
"NONE",
]);
const deny: AWS.IAM.PolicyStatement[] = [
{
Effect: "Deny",
Principal: { AWS: "*" },
Action: ["s3:PutEncryptionConfiguration"],
Resource: bucket.bucketArn,
},
];
const settings: "SSE-C"[][] = [["SSE-C"], []];
for (const types of settings) {
const probeWrite = S3.putBucketEncryption({
Bucket: bucket.bucketName,
ServerSideEncryptionConfiguration: {
Rules: [
{
ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" },
BucketKeyEnabled: false,
BlockedEncryptionTypes: {
EncryptionType: types.length ? types : ["NONE"],
},
},
],
},
}).pipe(
Effect.as(false),
Effect.catchTag("AccessDeniedException", () => Effect.succeed(true)),
);
const desired = program(types, "managed");
expect(
(yield* stack.plan(desired)).resources.ManagedEncryptionBlocks
?.action,
).toBe("update");
const updated = yield* stack.deploy(desired);
expect(updated.bucketName).toBe(bucket.bucketName);
const expected = types.length ? ["SSE-C"] : ["NONE"];
expect(
(yield* readRule).BlockedEncryptionTypes?.EncryptionType,
).toEqual(expected);
yield* Effect.gen(function* () {
yield* S3.putBucketPolicy({
Bucket: bucket.bucketName,
Policy: JSON.stringify({ Version: "2012-10-17", Statement: deny }),
});
expect(
yield* probeWrite.pipe(
Effect.repeat({
until: Boolean,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
),
).toBe(true);
// Tags force reconcile while equivalent restrictions must skip the denied PUT.
yield* stack.deploy(program([...types, ...types], "unchanged", deny));
if (!types.length) {
yield* stack.deploy(program(undefined, "omitted", deny));
}
expect(yield* probeWrite).toBe(true);
expect(
(yield* readRule).BlockedEncryptionTypes?.EncryptionType,
).toEqual(expected);
}).pipe(
Effect.ensuring(
S3.deleteBucketPolicy({ Bucket: bucket.bucketName }).pipe(
Effect.orDie,
),
),
);
expect(
yield* probeWrite.pipe(
Effect.repeat({
until: (denied) => !denied,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
),
).toBe(false);
yield* stack.deploy(program(undefined, "default"));
expect(
(yield* readRule).BlockedEncryptionTypes?.EncryptionType,
).toEqual(["NONE"]);
}
const desired = program([], "repair");
yield* stack.deploy(desired);
yield* S3.putBucketEncryption({
Bucket: bucket.bucketName,
ServerSideEncryptionConfiguration: {
Rules: [
{
ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" },
BlockedEncryptionTypes: { EncryptionType: ["SSE-C"] },
},
],
},
});
expect(
(yield* stack.plan(desired)).resources.ManagedEncryptionBlocks?.action,
).toBe("update");
yield* stack.deploy(desired);
expect((yield* readRule).BlockedEncryptionTypes?.EncryptionType).toEqual([
"NONE",
]);
expect(
(yield* stack.plan(program([], "repair"))).resources
.ManagedEncryptionBlocks?.action,
).toBe("noop");
yield* stack.destroy();
const absent = yield* S3.getBucketLocation({
Bucket: bucket.bucketName,
}).pipe(
Effect.as(false),
Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
Effect.repeat({
until: Boolean,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
expect(absent).toBe(true);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
);
test.provider(
"create and delete bucket with default props",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("DefaultBucket");
}),
);
expect(bucket.bucketName).toBeDefined();
expect(bucket.bucketArn).toBeDefined();
expect(bucket.region).toBeDefined();
yield* S3.headBucket({ Bucket: bucket.bucketName });
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"create, update, delete bucket",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("TestBucket", {
bucketName: "alchemy-test-bucket-crud",
tags: { Environment: "test" },
forceDestroy: true,
});
}),
);
yield* S3.headBucket({ Bucket: bucket.bucketName });
const tagging = yield* S3.getBucketTagging({
Bucket: bucket.bucketName,
});
expect(tagging.TagSet).toContainEqual({
Key: "Environment",
Value: "test",
});
yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("TestBucket", {
bucketName: "alchemy-test-bucket-crud",
tags: { Environment: "production", Team: "platform" },
forceDestroy: true,
});
}),
);
const updatedTagging = yield* S3.getBucketTagging({
Bucket: bucket.bucketName,
});
expect(updatedTagging.TagSet).toContainEqual({
Key: "Environment",
Value: "production",
});
expect(updatedTagging.TagSet).toContainEqual({
Key: "Team",
Value: "platform",
});
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"create bucket with custom name",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("CustomNameBucket", {
bucketName: "alchemy-test-bucket-custom-name",
forceDestroy: true,
});
}),
);
expect(bucket.bucketName).toEqual("alchemy-test-bucket-custom-name");
expect(bucket.bucketArn).toEqual(
"arn:aws:s3:::alchemy-test-bucket-custom-name",
);
yield* S3.headBucket({ Bucket: bucket.bucketName });
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"create bucket with forceDestroy",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("ForceDestroyBucket", {
bucketName: "alchemy-test-bucket-force-destroy",
forceDestroy: true,
});
}),
);
yield* S3.putObject({
Bucket: bucket.bucketName,
Key: "test-object.txt",
Body: "Hello, World!",
});
// A heavily parallel account sweep can briefly observe the successful
// PutObject before the object is readable through a different S3
// endpoint. Retry only the typed visibility miss, with a hard bound;
// authorization and request-shape errors still fail immediately.
yield* S3.headObject({
Bucket: bucket.bucketName,
Key: "test-object.txt",
}).pipe(
Effect.retry({
while: (error) => error._tag === "NotFound",
schedule: Schedule.max([
Schedule.exponential(100),
Schedule.recurs(6),
]),
}),
);
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"idempotent create - bucket already exists",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket1 = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("IdempotentBucket", {
bucketName: "alchemy-test-bucket-idempotent",
forceDestroy: true,
});
}),
);
const bucketName = bucket1.bucketName;
const bucket2 = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("IdempotentBucket", {
bucketName: "alchemy-test-bucket-idempotent",
forceDestroy: true,
});
}),
);
expect(bucket2.bucketName).toEqual(bucketName);
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"create bucket with objectLockEnabled",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("ObjectLockBucket", {
bucketName: "alchemy-test-bucket-object-lock",
objectLockEnabled: true,
forceDestroy: true,
});
}),
);
const objectLockConfig = yield* S3.getObjectLockConfiguration({
Bucket: bucket.bucketName,
});
expect(
objectLockConfig.ObjectLockConfiguration?.ObjectLockEnabled,
).toEqual("Enabled");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"remove all tags from bucket",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("TagRemovalBucket", {
bucketName: "alchemy-test-bucket-tag-removal",
tags: { Environment: "test", Team: "platform" },
forceDestroy: true,
});
}),
);
const bucketName = bucket.bucketName;
const tagging = yield* S3.getBucketTagging({
Bucket: bucketName,
});
expect(tagging.TagSet).toHaveLength(2);
yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("TagRemovalBucket", {
bucketName: "alchemy-test-bucket-tag-removal",
forceDestroy: true,
});
}),
);
const result = yield* S3.getBucketTagging({
Bucket: bucketName,
}).pipe(
Effect.map(() => "has-tags" as const),
Effect.catchTag("NoSuchTagSet", () =>
Effect.succeed("no-tags" as const),
),
);
expect(result).toEqual("no-tags");
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"create and remove bucket policy from bindings",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const distributionArn =
"arn:aws:cloudfront::123456789012:distribution/TESTDIST";
const bucketArn = "arn:aws:s3:::alchemy-test-bucket-policy-bindings";
const bucket = yield* stack.deploy(
Effect.gen(function* () {
const bucket = yield* Bucket("PolicyBucket", {
bucketName: "alchemy-test-bucket-policy-bindings",
forceDestroy: true,
});
yield* bucket.bind("AWS.S3.Policy(TestDistribution, PolicyBucket)", {
policyStatements: [
{
Effect: "Allow",
Principal: {
Service: "cloudfront.amazonaws.com",
},
Action: ["s3:GetObject"],
Resource: [`${bucketArn}/*`],
Condition: {
StringEquals: {
"AWS:SourceArn": distributionArn,
},
},
},
],
});
return bucket;
}),
);
const bucketPolicy = yield* S3.getBucketPolicy({
Bucket: bucket.bucketName,
}).pipe(Effect.map((response) => JSON.parse(response.Policy!)));
const statement = bucketPolicy.Statement[0];
expect(bucketPolicy.Version).toEqual("2012-10-17");
expect(statement.Effect).toEqual("Allow");
expect(statement.Principal).toEqual({
Service: "cloudfront.amazonaws.com",
});
expect(statement.Action).toEqual("s3:GetObject");
expect(statement.Resource).toEqual(`${bucketArn}/*`);
expect(statement.Condition).toEqual({
StringEquals: {
"AWS:SourceArn": distributionArn,
},
});
yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("PolicyBucket", {
bucketName: "alchemy-test-bucket-policy-bindings",
forceDestroy: true,
});
}),
);
const policyAfterRemoval = yield* S3.getBucketPolicy({
Bucket: bucket.bucketName,
}).pipe(
Effect.map(() => "has-policy" as const),
Effect.catchTag("NoSuchBucketPolicy", () =>
Effect.succeed("no-policy" as const),
),
);
expect(policyAfterRemoval).toEqual("no-policy");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
// Engine-level adoption: S3 has no per-stack ownership signal (we don't
// stamp alchemy tags on buckets — the canonical existence check is
// `headBucket`, which only succeeds when the bucket is owned by *this AWS
// account*). So a name match means we own it at the account level — silent
// adoption is correct for the cold-start case.
test.provider(
"owned bucket (account-level) is silently adopted without --adopt",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
// Deterministic name: the state wipe below intentionally makes the
// bucket invisible to the scratch stack's auto-destroy for a window, so
// the name must be stable across runs — a re-run then reclaims any
// orphan left by a previously-killed run instead of minting a new one.
const bucketName = "alchemy-test-s3-adopt-bucket";
// Idempotent pre-clean: reclaim a leftover from a prior interrupted run.
yield* deleteBucketIfExists(bucketName);
yield* Effect.gen(function* () {
const initial = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("AdoptableBucket", {
bucketName,
forceDestroy: true,
});
}),
);
expect(initial.bucketName).toEqual(bucketName);
yield* S3.putBucketEncryption({
Bucket: bucketName,
ServerSideEncryptionConfiguration: {
Rules: [
{
ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "aws:kms" },
BucketKeyEnabled: true,
BlockedEncryptionTypes: { EncryptionType: ["NONE"] },
},
],
},
});
// Wipe state — bucket stays in S3.
yield* Effect.gen(function* () {
const state = yield* yield* State;
yield* state.delete({
stack: stack.name,
stage: stack.stage,
fqn: "AdoptableBucket",
});
}).pipe(Effect.provide(stack.state));
const adopted = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("AdoptableBucket", {
bucketName,
forceDestroy: true,
});
}),
);
expect(adopted.bucketArn).toEqual(initial.bucketArn);
const defaults = (yield* S3.getBucketEncryption({ Bucket: bucketName }))
.ServerSideEncryptionConfiguration!.Rules[0]!;
expect(defaults.ApplyServerSideEncryptionByDefault?.SSEAlgorithm).toBe(
"AES256",
);
expect(
defaults.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID,
).toBeUndefined();
expect(defaults.BucketKeyEnabled ?? false).toBe(false);
expect(defaults.BlockedEncryptionTypes?.EncryptionType).toEqual([
"NONE",
]);
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}).pipe(
// Guaranteed out-of-band cleanup: while state is wiped the bucket is
// outside the scratch stack, so the framework's ensuring-destroy can't
// see it. This finalizer runs on success, failure, AND interruption;
// it's a no-op when stack.destroy already deleted the bucket.
Effect.ensuring(deleteBucketIfExists(bucketName).pipe(Effect.ignore)),
);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
// Canonical `list()` test (AWS account/region-scoped collection): deploy a
// real bucket, resolve the provider from context via `findProviderByType`,
// call `list()`, and assert the deployed bucket appears in the
// exhaustively-paginated result.
test.provider(
"list enumerates the deployed bucket",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const bucket = yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("ListBucket", {
bucketName: "alchemy-test-bucket-list",
forceDestroy: true,
});
}),
);
const provider = yield* Provider.findProvider(Bucket);
const all = yield* provider.list();
expect(all.some((b) => b.bucketName === bucket.bucketName)).toBe(true);
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"versioning enable then suspend",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-versioning";
const bucket = yield* stack.deploy(
Bucket("VersioningBucket", {
bucketName: name,
versioning: "Enabled",
forceDestroy: true,
}),
);
const v1 = yield* S3.getBucketVersioning({ Bucket: bucket.bucketName });
expect(v1.Status).toEqual("Enabled");
yield* stack.deploy(
Bucket("VersioningBucket", {
bucketName: name,
versioning: "Suspended",
forceDestroy: true,
}),
);
const v2 = yield* S3.getBucketVersioning({ Bucket: bucket.bucketName });
expect(v2.Status).toEqual("Suspended");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"encryption SSE-S3 set and update bucketKey",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-encryption";
const bucket = yield* stack.deploy(
Bucket("EncryptionBucket", {
bucketName: name,
encryption: { sseAlgorithm: "AES256" },
forceDestroy: true,
}),
);
const e1 = yield* S3.getBucketEncryption({ Bucket: bucket.bucketName });
expect(
e1.ServerSideEncryptionConfiguration?.Rules?.[0]
?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm,
).toEqual("AES256");
yield* stack.deploy(
Bucket("EncryptionBucket", {
bucketName: name,
encryption: { sseAlgorithm: "AES256", bucketKeyEnabled: true },
forceDestroy: true,
}),
);
const e2 = yield* S3.getBucketEncryption({ Bucket: bucket.bucketName });
expect(
e2.ServerSideEncryptionConfiguration?.Rules?.[0]?.BucketKeyEnabled,
).toEqual(true);
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"public access block set, update, remove",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-pab";
const bucket = yield* stack.deploy(
Bucket("PabBucket", {
bucketName: name,
publicAccessBlock: {
blockPublicAcls: true,
ignorePublicAcls: true,
blockPublicPolicy: true,
restrictPublicBuckets: true,
},
forceDestroy: true,
}),
);
const p1 = yield* S3.getPublicAccessBlock({ Bucket: bucket.bucketName });
expect(p1.PublicAccessBlockConfiguration?.BlockPublicAcls).toEqual(true);
expect(p1.PublicAccessBlockConfiguration?.RestrictPublicBuckets).toEqual(
true,
);
yield* stack.deploy(
Bucket("PabBucket", {
bucketName: name,
publicAccessBlock: {
blockPublicAcls: true,
ignorePublicAcls: false,
blockPublicPolicy: true,
restrictPublicBuckets: false,
},
forceDestroy: true,
}),
);
const p2 = yield* S3.getPublicAccessBlock({ Bucket: bucket.bucketName });
expect(p2.PublicAccessBlockConfiguration?.RestrictPublicBuckets).toEqual(
false,
);
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"cors add rule then remove all",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-cors";
const bucket = yield* stack.deploy(
Bucket("CorsBucket", {
bucketName: name,
cors: [
{
AllowedMethods: ["GET"],
AllowedOrigins: ["https://example.com"],
AllowedHeaders: ["*"],
MaxAgeSeconds: 3000,
},
],
forceDestroy: true,
}),
);
const c1 = yield* S3.getBucketCors({ Bucket: bucket.bucketName });
expect(c1.CORSRules).toHaveLength(1);
yield* stack.deploy(
Bucket("CorsBucket", {
bucketName: name,
cors: [
{
AllowedMethods: ["GET"],
AllowedOrigins: ["https://example.com"],
AllowedHeaders: ["*"],
MaxAgeSeconds: 3000,
},
{
AllowedMethods: ["PUT", "POST"],
AllowedOrigins: ["https://app.example.com"],
},
],
forceDestroy: true,
}),
);
const c2 = yield* S3.getBucketCors({ Bucket: bucket.bucketName });
expect(c2.CORSRules).toHaveLength(2);
yield* stack.deploy(
Bucket("CorsBucket", {
bucketName: name,
cors: [],
forceDestroy: true,
}),
);
const removed = yield* S3.getBucketCors({
Bucket: bucket.bucketName,
}).pipe(
Effect.map(() => "has-cors" as const),
Effect.catchTag("NoSuchCORSConfiguration", () =>
Effect.succeed("no-cors" as const),
),
);
expect(removed).toEqual("no-cors");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"lifecycle add rule then remove",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-lifecycle";
const bucket = yield* stack.deploy(
Bucket("LifecycleBucket", {
bucketName: name,
lifecycleRules: [
{
ID: "expire-logs",
Status: "Enabled",
Filter: { Prefix: "logs/" },
Expiration: { Days: 30 },
AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 },
},
],
forceDestroy: true,
}),
);
const l1 = yield* S3.getBucketLifecycleConfiguration({
Bucket: bucket.bucketName,
});
expect(l1.Rules).toHaveLength(1);
expect(l1.Rules?.[0]?.Expiration?.Days).toEqual(30);
yield* stack.deploy(
Bucket("LifecycleBucket", {
bucketName: name,
lifecycleRules: [],
forceDestroy: true,
}),
);
// Lifecycle config is eventually consistent — the rule can linger on reads
// for a few seconds after deleteBucketLifecycle. Retry until it clears.
const removed = yield* S3.getBucketLifecycleConfiguration({
Bucket: bucket.bucketName,
}).pipe(
Effect.map(() => "has-lifecycle" as const),
Effect.catchTag("NoSuchLifecycleConfiguration", () =>
Effect.succeed("no-lifecycle" as const),
),
Effect.repeat({
schedule: Schedule.spaced("3 seconds"),
until: (r) => r === "no-lifecycle",
// S3 lifecycle deletion can take well over 30s to become visible on
// reads. Give it ~90s (30 × 3s) before failing — the loop short-circuits
// via `until` the moment the config clears, so this only costs wall-clock
// time on the (rare) slow-propagation runs that were flaking at times: 10.
times: 30,
}),
);
expect(removed).toEqual("no-lifecycle");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"ownership controls and website hosting serves index.html",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-website";
const bucket = yield* stack.deploy(
Bucket("WebsiteBucket", {
bucketName: name,
objectOwnership: "BucketOwnerPreferred",
// The website endpoint serves anonymously, so the bucket must allow
// a public-read policy: PAB flags all off + a public GetObject
// statement. (publicAccessBlock syncs before policy in reconcile.)
publicAccessBlock: {
blockPublicAcls: false,
ignorePublicAcls: false,
blockPublicPolicy: false,
restrictPublicBuckets: false,
},
policy: [
{
Sid: "PublicReadGetObject",
Effect: "Allow",
Principal: { AWS: "*" },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${name}/*`],
},
],
website: {
indexDocument: { suffix: "index.html" },
errorDocument: { key: "error.html" },
},
forceDestroy: true,
}),
);
const own = yield* S3.getBucketOwnershipControls({
Bucket: bucket.bucketName,
});
expect(own.OwnershipControls?.Rules?.[0]?.ObjectOwnership).toEqual(
"BucketOwnerPreferred",
);
const web = yield* S3.getBucketWebsite({ Bucket: bucket.bucketName });
expect(web.IndexDocument?.Suffix).toEqual("index.html");
expect(web.ErrorDocument?.Key).toEqual("error.html");
// Prove the website endpoint actually serves: upload an index document
// and GET it anonymously over HTTP (marker-anchored, with propagation
// retry — fresh website endpoints can take a few seconds to serve).
const marker = "alchemy-s3-website-test-marker";
yield* S3.putObject({
Bucket: bucket.bucketName,
Key: "index.html",
Body: `<html><body>${marker}</body></html>`,
ContentType: "text/html",
});
const websiteUrl = `http://${name}.s3-website-${bucket.region}.amazonaws.com/`;
const body = yield* HttpClient.get(websiteUrl).pipe(
Effect.flatMap((response) =>
response.status === 200
? response.text
: Effect.fail(
new Error(`website endpoint returned ${response.status}`),
),
),
Effect.retry({
schedule: Schedule.max([
Schedule.exponential(1000),
Schedule.recurs(8),
]),
}),
);
expect(body).toContain(marker);
// In-place update of the index document; website config is in-place
// updatable. (Omitting the prop leaves the config untouched — to clear it
// a user removes the resource; we never silently drop unmanaged config.)
yield* stack.deploy(
Bucket("WebsiteBucket", {
bucketName: name,
objectOwnership: "BucketOwnerPreferred",
publicAccessBlock: {
blockPublicAcls: false,
ignorePublicAcls: false,
blockPublicPolicy: false,
restrictPublicBuckets: false,
},
policy: [
{
Sid: "PublicReadGetObject",
Effect: "Allow",
Principal: { AWS: "*" },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${name}/*`],
},
],
website: {
indexDocument: { suffix: "home.html" },
errorDocument: { key: "error.html" },
},
forceDestroy: true,
}),
);
const web2 = yield* S3.getBucketWebsite({ Bucket: bucket.bucketName });
expect(web2.IndexDocument?.Suffix).toEqual("home.html");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"], timeout: 120_000 },
);
// Server-access logging needs a same-stack target bucket that grants the S3
// logging service principal permission to deliver logs (the modern, ACL-free
// grant — works with the BucketOwnerEnforced default).
test.provider(
"server access logging set and update prefix",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const target = "alchemy-test-bucket-logging-target";
const source = "alchemy-test-bucket-logging-src";
const deployWith = (targetPrefix: string) =>
stack.deploy(
Effect.gen(function* () {
const targetBucket = yield* Bucket("LogTargetBucket", {
bucketName: target,
policy: [
{
Sid: "S3ServerAccessLogsPolicy",
Effect: "Allow",
Principal: { Service: "logging.s3.amazonaws.com" },
Action: ["s3:PutObject"],
Resource: [`arn:aws:s3:::${target}/*`],
Condition: {
ArnLike: { "aws:SourceArn": `arn:aws:s3:::${source}` },
},
},
],
forceDestroy: true,
});
const sourceBucket = yield* Bucket("LogSourceBucket", {
bucketName: source,
logging: {
// Output reference so the target (and its delivery policy)
// reconciles before the source's putBucketLogging.
targetBucket: targetBucket.bucketName,
targetPrefix,
},
forceDestroy: true,
});
return { sourceBucket, targetBucket };
}),
);
const buckets = yield* deployWith("logs/");
const l1 = yield* S3.getBucketLogging({
Bucket: buckets.sourceBucket.bucketName,
});
expect(l1.LoggingEnabled?.TargetBucket).toEqual(target);
expect(l1.LoggingEnabled?.TargetPrefix).toEqual("logs/");
// In-place update of the log prefix.
yield* deployWith("access/");
const l2 = yield* S3.getBucketLogging({
Bucket: buckets.sourceBucket.bucketName,
});
expect(l2.LoggingEnabled?.TargetPrefix).toEqual("access/");
yield* stack.destroy();
yield* assertBucketDeleted(buckets.sourceBucket.bucketName);
yield* assertBucketDeleted(buckets.targetBucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"transfer acceleration and request payment",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-accel-pay";
const bucket = yield* stack.deploy(
Bucket("AccelPayBucket", {
bucketName: name,
transferAcceleration: "Enabled",
requestPayer: "Requester",
forceDestroy: true,
}),
);
const a1 = yield* S3.getBucketAccelerateConfiguration({
Bucket: bucket.bucketName,
});
expect(a1.Status).toEqual("Enabled");
const r1 = yield* S3.getBucketRequestPayment({
Bucket: bucket.bucketName,
});
expect(r1.Payer).toEqual("Requester");
yield* stack.deploy(
Bucket("AccelPayBucket", {
bucketName: name,
transferAcceleration: "Suspended",
requestPayer: "BucketOwner",
forceDestroy: true,
}),
);
const a2 = yield* S3.getBucketAccelerateConfiguration({
Bucket: bucket.bucketName,
});
expect(a2.Status).toEqual("Suspended");
const r2 = yield* S3.getBucketRequestPayment({
Bucket: bucket.bucketName,
});
expect(r2.Payer).toEqual("BucketOwner");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"object lock default retention",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-objlock-retention";
const bucket = yield* stack.deploy(
Bucket("ObjLockRetentionBucket", {
bucketName: name,
objectLockEnabled: true,
objectLockConfiguration: { mode: "GOVERNANCE", days: "1 day" },
forceDestroy: true,
}),
);
const cfg = yield* S3.getObjectLockConfiguration({
Bucket: bucket.bucketName,
});
expect(cfg.ObjectLockConfiguration?.Rule?.DefaultRetention?.Mode).toEqual(
"GOVERNANCE",
);
expect(cfg.ObjectLockConfiguration?.Rule?.DefaultRetention?.Days).toEqual(
1,
);
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"intelligent tiering add and remove id",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-int-tiering";
const bucket = yield* stack.deploy(
Bucket("IntTieringBucket", {
bucketName: name,
intelligentTiering: [
{
Id: "archive",
Status: "Enabled",
Tierings: [{ Days: 90, AccessTier: "ARCHIVE_ACCESS" }],
},
],
forceDestroy: true,
}),
);
const t1 = yield* S3.getBucketIntelligentTieringConfiguration({
Bucket: bucket.bucketName,
Id: "archive",
});
expect(t1.IntelligentTieringConfiguration?.Status).toEqual("Enabled");
yield* stack.deploy(
Bucket("IntTieringBucket", {
bucketName: name,
intelligentTiering: [],
forceDestroy: true,
}),
);
const removed = yield* S3.getBucketIntelligentTieringConfiguration({
Bucket: bucket.bucketName,
Id: "archive",
}).pipe(
Effect.map(() => "has-config" as const),
Effect.catchTag("NoSuchConfiguration", () =>
Effect.succeed("no-config" as const),
),
);
expect(removed).toEqual("no-config");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"explicit bucket policy prop",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const name = "alchemy-test-bucket-policy-prop";
const bucketArn = `arn:aws:s3:::${name}`;
const bucket = yield* stack.deploy(
Bucket("PolicyPropBucket", {
bucketName: name,
policy: [
{
Sid: "AllowCloudFront",
Effect: "Allow",
Principal: { Service: "cloudfront.amazonaws.com" },
Action: ["s3:GetObject"],
Resource: [`${bucketArn}/*`],
},
],
forceDestroy: true,
}),
);
const policy = yield* S3.getBucketPolicy({
Bucket: bucket.bucketName,
}).pipe(Effect.map((r) => JSON.parse(r.Policy!)));
expect(policy.Statement[0].Sid).toEqual("AllowCloudFront");
yield* stack.destroy();
yield* assertBucketDeleted(bucket.bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
// Replication needs an IAM role S3 can assume + a versioned destination bucket.
// The test provisions all of it (role + dest bucket) so it is self-contained.
test.provider(
"replication configuration",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const dest = "alchemy-test-bucket-replication-dest";
const src = "alchemy-test-bucket-replication-src";
const buckets = yield* stack.deploy(
Effect.gen(function* () {
const destBucket = yield* Bucket("ReplDestBucket", {
bucketName: dest,
versioning: "Enabled",
forceDestroy: true,
});
const replRole = yield* Role("ReplRole", {
roleName: "alchemy-test-s3-replication-role",
assumeRolePolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "s3.amazonaws.com" },
Action: ["sts:AssumeRole"],
},
],
},
inlinePolicies: {
Replication: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: ["s3:GetReplicationConfiguration", "s3:ListBucket"],
Resource: [`arn:aws:s3:::${src}`],
},
{
Effect: "Allow",
Action: [
"s3:GetObjectVersionForReplication",
"s3:GetObjectVersionAcl",
"s3:GetObjectVersionTagging",
],
Resource: [`arn:aws:s3:::${src}/*`],
},
{
Effect: "Allow",
Action: [
"s3:ReplicateObject",
"s3:ReplicateDelete",
"s3:ReplicateTags",
],
Resource: [`arn:aws:s3:::${dest}/*`],
},
],
},
},
});
const srcBucket = yield* Bucket("ReplSrcBucket", {
bucketName: src,
versioning: "Enabled",
replication: {
role: replRole.roleArn,
rules: [
{
ID: "replicate-all",
Status: "Enabled",
Priority: 1,
Filter: {},
DeleteMarkerReplication: { Status: "Disabled" },
Destination: { Bucket: `arn:aws:s3:::${dest}` },
},
],
},
forceDestroy: true,
});
return { srcBucket, destBucket, roleArn: replRole.roleArn };
}),
);
const repl = yield* S3.getBucketReplication({
Bucket: buckets.srcBucket.bucketName,
});
expect(repl.ReplicationConfiguration?.Role).toEqual(buckets.roleArn);
expect(repl.ReplicationConfiguration?.Rules).toHaveLength(1);
yield* stack.destroy();
yield* assertBucketDeleted(buckets.srcBucket.bucketName);
yield* assertBucketDeleted(buckets.destBucket.bucketName);
}),
{
tags: ["provider:aws", "provider:aws:iam", "provider:aws:s3", "live"],
timeout: 180_000,
},
);
// Idempotent out-of-band delete for the adoption test's deterministically
// named bucket. Tolerates the bucket never existing / already deleted
// (typed NoSuchBucket) and retries transient failures a bounded number of
// times. The bucket is always empty in that test, so deleteBucket suffices.
const deleteBucketIfExists = Effect.fn(function* (bucketName: string) {
yield* S3.deleteBucket({ Bucket: bucketName }).pipe(
Effect.catchTag("NoSuchBucket", () => Effect.void),
Effect.retry({
schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(5)]),
}),
);
});
class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
const assertBucketDeleted = Effect.fn(function* (bucketName: string) {
yield* S3.headBucket({ Bucket: bucketName }).pipe(
Effect.flatMap(() => Effect.fail(new BucketStillExists())),
Effect.retry({
while: (e) => e._tag === "BucketStillExists",
schedule: Schedule.max([Schedule.exponential(100), Schedule.recurs(10)]),
}),
Effect.catchTag("NotFound", () => Effect.void),
Effect.catch(() => Effect.void),
);
});
// ── destructive deletes require explicit opt-in ────────────────────────
//
// DATA-PROTECTION INVARIANT: `delete` may remove the bucket, but it must
// NEVER destroy the bucket's CONTENTS unless the user opted in on the
// resource (`forceDestroy`) or an operator ran `alchemy unsafe nuke`
// (which passes `force: true`).
//
// S3 refuses to delete a non-empty bucket (`BucketNotEmpty`). That refusal
// is the last line of defense for production data, and emptying the bucket
// first silently converts a routine teardown into irreversible data loss
// (see https://github.com/alchemy-run/alchemy/issues/1248 for the R2
// incident this guards against here too).
//
// A live test can only observe that a bucket survived, not that no
// destructive request was ever issued. These run the REAL provider
// `delete` against a recording transport and assert on the wire traffic.
type Recorded = { method: string; url: string };
/** Fetch transport that records every request and answers from `respond`. */
const recordingTransport = (respond: (call: Recorded) => Response) => {
const calls: Recorded[] = [];
const fetch = async (
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
calls.push({
method: (input instanceof Request ? input.method : init?.method) ?? "GET",
url: input instanceof Request ? input.url : String(input),
});
return respond(calls[calls.length - 1]!);
};
return {
calls,
layer: FetchHttpClient.layer.pipe(
Layer.provide(
Layer.succeed(FetchHttpClient.Fetch, fetch as typeof globalThis.fetch),
),
),
};
};
const TEST_REGION = "us-east-1";
const INSTANCE_ID = "0123456789abcdef0123456789abcdef";
const testStack: Omit<StackSpec, "output"> = {
name: "my-stack",
stage: "dev",
resources: {},
bindings: {},
actions: {},
};
// Built with distilled's own helper: the signer reads credentials through
// distilled's copy of `effect`, whose `Redacted` values a `Redacted.make`
// from this package's copy cannot unwrap.
const testCredentials = fromCredentials(
{ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "test-secret-key" },
TEST_REGION,
);
const stubbedEnv = (transport: Layer.Layer<HttpClient.HttpClient>) =>
Layer.mergeAll(
Layer.effect(
AWSEnvironment,
Effect.map(Credentials, (credentials) =>
Effect.succeed({
accountId: "123456789012",
region: TEST_REGION,
credentials,
} as never),
),
).pipe(Layer.provide(testCredentials)),
testCredentials,
Layer.succeed(Region, Effect.succeed(TEST_REGION)),
Layer.succeed(Stack, testStack),
Layer.succeed(Stage, testStack.stage),
Layer.succeed(InstanceId, INSTANCE_ID),
Layer.succeed(AlchemyContext, {
dotAlchemy: "/tmp/.alchemy-test",
dev: false,
adopt: false,
}),
Layer.sync(ArtifactStore, createArtifactStore),
inMemoryState(),
NodeServices.layer,
).pipe(Layer.provideMerge(transport));
const stubbedOutput = {
bucketName: "my-bucket",
bucketArn: "arn:aws:s3:::my-bucket",
region: TEST_REGION,
bucketDomainName: "my-bucket.s3.amazonaws.com",
bucketRegionalDomainName: "my-bucket.s3.us-east-1.amazonaws.com",
hostedZoneId: "Z3AQBSTGFYJSTF",
tags: {},
};
/** `POST /?delete` — S3's bulk object/version delete. */
const objectDeletes = (calls: Recorded[]) =>
calls.filter((c) => c.method === "POST" && /[?&]delete/.test(c.url));
/** `GET /?versions` — enumerating what to wipe. */
const versionListings = (calls: Recorded[]) =>
calls.filter((c) => c.method === "GET" && /[?&]versions/.test(c.url));
const bucketDeletes = (calls: Recorded[]) =>
calls.filter((c) => c.method === "DELETE" && !/[?&]/.test(c.url));
/** One object version, so the empty path has something to delete. */
const stubResponse = (call: Recorded) =>
/[?&]versions/.test(call.url)
? new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<ListVersionsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>my-bucket</Name>
<IsTruncated>false</IsTruncated>
<Version>
<Key>precious.txt</Key>
<VersionId>null</VersionId>
</Version>
</ListVersionsResult>`,
{ status: 200, headers: { "content-type": "application/xml" } },
)
: new Response("", {
status: 204,
headers: { "content-type": "application/xml" },
});
/** Run the real provider delete; return every request it made. */
const recordDelete = (
props: { forceDestroy?: boolean },
options?: { force?: boolean },
) =>
Effect.gen(function* () {
const transport = recordingTransport(stubResponse);
yield* Effect.gen(function* () {
const provider = yield* Provider.Provider<Bucket>("AWS.S3.Bucket");
yield* provider.delete({
id: "Bucket",
fqn: "Bucket",
instanceId: INSTANCE_ID,
olds: props,
output: stubbedOutput as never,
bindings: [] as never,
session: {
emit: () => Effect.void,
done: () => Effect.void,
note: () => Effect.void,
},
force: options?.force,
});
}).pipe(
Effect.provide(BucketProvider()),
Effect.provide(stubbedEnv(transport.layer)),
);
return transport.calls;
});
describe(
"destructive delete requires explicit opt-in",
{ tags: ["unit", "provider:aws", "provider:aws:s3", "local"] },
() => {
it.effect("no forceDestroy never empties the bucket", () =>
Effect.gen(function* () {
const calls = yield* recordDelete({});
expect(objectDeletes(calls)).toEqual([]);
expect(versionListings(calls)).toEqual([]);
// The bucket delete itself is still attempted — S3 answers
// `BucketNotEmpty`, which is the protection.
expect(bucketDeletes(calls).length).toBeGreaterThan(0);
}),
);
it.effect("forceDestroy empties the bucket first", () =>
Effect.gen(function* () {
const calls = yield* recordDelete({ forceDestroy: true });
expect(objectDeletes(calls).length).toBeGreaterThan(0);
expect(bucketDeletes(calls).length).toBeGreaterThan(0);
}),
);
// Nuke enumerates buckets from the cloud, so `olds` carries Attributes and
// never has `forceDestroy` — the operator's confirmation IS the flag.
it.effect("nuke's force empties without the prop", () =>
Effect.gen(function* () {
const calls = yield* recordDelete({}, { force: true });
expect(objectDeletes(calls).length).toBeGreaterThan(0);
}),
);
},
);