mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-13 22:07:44 +02:00
2067 lines
67 KiB
TypeScript
2067 lines
67 KiB
TypeScript
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);
|
||
}),
|
||
);
|
||
},
|
||
);
|