1import { Action } from "@/Action";
2import * as AWS from "@/AWS";
3import { AWSEnvironment } from "@/AWS/Environment.ts";
4import { flociServices } from "@/AWS/Local/FlociServices.ts";
5import { Bucket, PutObject, PutObjectHttp } from "@/AWS/S3";
6import { remote } from "@/ProviderMode.ts";
7import * as Test from "@/Test/Alchemy";
8import { liveContext } from "../Local/fixtures/live.ts";
9import * as S3 from "@distilled.cloud/aws/s3";
10import { expect } from "alchemy-test";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Schedule from "effect/Schedule";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// The Floci sweep must override even a configured live AWS environment. This
19// sentinel makes the Action regression independent of local profiles.
20const forceLocal = process.env.ALCHEMY_TEST_DEV === "1";
21const actionProviders = forceLocal
22 ? Layer.merge(
23 AWS.providers(),
24 Layer.succeed(
25 AWSEnvironment,
26 Effect.succeed({
27 accountId: "111111111111",
28 region: "us-east-1",
29 credentials: Effect.die("live credentials must not be used"),
30 }),
31 ),
32 )
33 : AWS.providers();
34const { test: actionTest } = Test.make({ providers: actionProviders });
35
36const deployTestBucket = (stack: Test.ScratchStack) =>
37 Effect.gen(function* () {
38 // Leading destroy: reconcile away any partial deployment left by a
39 // previously crashed run (the auto-generated physical name is
40 // deterministic, so the re-deploy below re-adopts and owns it).
41 yield* stack.destroy();
42 return yield* stack.deploy(
43 Effect.gen(function* () {
44 return yield* Bucket("DataPlaneTestBucket", {
45 forceDestroy: true,
46 });
47 }),
48 );
49 });
50
51// The PRODUCTION dev path: plain `dev: true` (no ALCHEMY_TEST_DEV sweep), so
52// the ambient AWS environment stays whatever the profile resolves — live
53// credentials under `--profile testing`. Binding clients invoked inside an
54// Action must route per bound resource regardless: a local-mode bucket's
55// calls land on the floci emulator, an `Alchemy.remote()` bucket's calls
56// land on the real cloud. Skipped under the sweep, which pins the whole
57// process to floci and would make the live-vs-local distinction vacuous.
58const { test: devTest } = Test.make({ providers: AWS.providers(), dev: true });
59const inSweep = process.env.ALCHEMY_TEST_DEV === "1";
60
61devTest.provider.skipIf(inSweep)(
62 "dev Action routes to the local bucket's emulator data plane",
63 (stack) =>
64 Effect.gen(function* () {
65 yield* stack.destroy();
66
67 const output = yield* stack.deploy(
68 Effect.gen(function* () {
69 const bucket = yield* Bucket("DevLocalActionBucket", {
70 forceDestroy: true,
71 });
72 const PutLocal = Action(
73 "PutLocalObject",
74 Effect.gen(function* () {
75 const putObject = yield* PutObject(bucket);
76 return () =>
77 Effect.gen(function* () {
78 const result = yield* putObject({
79 Key: "local.txt",
80 Body: "routed to the emulator",
81 });
82 return { etag: result.ETag };
83 });
84 }).pipe(Effect.provide(PutObjectHttp)),
85 );
86 return { bucketName: bucket.bucketName, put: yield* PutLocal({}) };
87 }),
88 );
89 expect(output.put.etag).toBeDefined();
90
91 // The object landed on the emulator...
92 const local = yield* S3.headObject({
93 Bucket: output.bucketName,
94 Key: "local.txt",
95 }).pipe(Effect.provide(flociServices()));
96 expect(local.ETag).toBe(output.put.etag);
97
98 // ...and the bucket never existed on the real cloud. Ambient in a
99 // `dev` run is the emulator, so the live probe must opt into the
100 // registration-captured live chain (see Actions.local.test.ts).
101 const live = yield* S3.headBucket({ Bucket: output.bucketName }).pipe(
102 Effect.map(() => "found" as const),
103 Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
104 Effect.provide(liveContext),
105 );
106 expect(live).toBe("not-found");
107
108 yield* stack.destroy();
109 }),
110 { tags: ["provider:aws", "provider:aws:s3", "live"] },
111);
112
113devTest.provider.skipIf(inSweep)(
114 "dev Action on a remote() bucket targets the real cloud",
115 (stack) =>
116 Effect.gen(function* () {
117 yield* stack.destroy();
118
119 const output = yield* stack.deploy(
120 Effect.gen(function* () {
121 const bucket = yield* Bucket("DevRemoteActionBucket", {
122 forceDestroy: true,
123 }).pipe(remote());
124 const PutRemote = Action(
125 "PutRemoteObject",
126 Effect.gen(function* () {
127 const putObject = yield* PutObject(bucket);
128 return () =>
129 Effect.gen(function* () {
130 const result = yield* putObject({
131 Key: "remote.txt",
132 Body: "landed on real AWS",
133 });
134 return { etag: result.ETag };
135 });
136 }).pipe(Effect.provide(PutObjectHttp)),
137 );
138 return { bucketName: bucket.bucketName, put: yield* PutRemote({}) };
139 }),
140 );
141 expect(output.put.etag).toBeDefined();
142
143 // Out-of-band via the live SDK: the write landed on the real bucket.
144 // Ambient in a `dev` run is the emulator, so this probe (and the
145 // post-destroy check) must opt into the registration-captured live
146 // chain.
147 yield* Effect.gen(function* () {
148 const live = yield* S3.headObject({
149 Bucket: output.bucketName,
150 Key: "remote.txt",
151 });
152 expect(live.ETag).toBe(output.put.etag);
153
154 yield* stack.destroy();
155 yield* assertBucketDeleted(output.bucketName);
156 }).pipe(Effect.provide(liveContext));
157 }),
158 { tags: ["provider:aws", "provider:aws:s3", "live"] },
159);
160
161actionTest.provider(
162 "Action - uses the configured S3 data plane",
163 (stack) =>
164 Effect.gen(function* () {
165 yield* stack.destroy();
166
167 const output = yield* stack.deploy(
168 Effect.gen(function* () {
169 const bucket = yield* Bucket("ActionDataPlaneBucket", {
170 forceDestroy: true,
171 });
172 const PutWithAction = Action(
173 "PutWithAction",
174 Effect.gen(function* () {
175 const putObject = yield* PutObject(bucket);
176 return () =>
177 Effect.gen(function* () {
178 const environment = yield* AWSEnvironment.current;
179 if (forceLocal && environment.accountId !== "000000000000") {
180 return { accountId: environment.accountId };
181 }
182 const result = yield* putObject({
183 Key: "action.txt",
184 Body: "hello from Action",
185 });
186 return {
187 accountId: environment.accountId,
188 etag: result.ETag,
189 };
190 });
191 }).pipe(Effect.provide(PutObjectHttp)),
192 );
193 return yield* PutWithAction({});
194 }),
195 );
196
197 expect(output.etag).toBeDefined();
198 if (forceLocal) {
199 expect(output.accountId).toBe("000000000000");
200 }
201 yield* stack.destroy();
202 }),
203 { tags: ["provider:aws", "provider:aws:s3", "live"] },
204);
205
206test.provider(
207 "listObjectsV2 - list objects in bucket",
208 (stack) =>
209 Effect.gen(function* () {
210 const bucket = yield* deployTestBucket(stack);
211 const bucketName = bucket.bucketName;
212
213 yield* S3.putObject({
214 Bucket: bucketName,
215 Key: "file1.txt",
216 Body: "content 1",
217 });
218 yield* S3.putObject({
219 Bucket: bucketName,
220 Key: "file2.txt",
221 Body: "content 2",
222 });
223 yield* S3.putObject({
224 Bucket: bucketName,
225 Key: "folder/file3.txt",
226 Body: "content 3",
227 });
228
229 const result = yield* S3.listObjectsV2({
230 Bucket: bucketName,
231 });
232
233 expect(result.Contents).toBeDefined();
234 expect(result.Contents!.length).toBe(3);
235 expect(result.Contents!.map((c) => c.Key)).toContain("file1.txt");
236 expect(result.Contents!.map((c) => c.Key)).toContain("file2.txt");
237 expect(result.Contents!.map((c) => c.Key)).toContain("folder/file3.txt");
238
239 const prefixResult = yield* S3.listObjectsV2({
240 Bucket: bucketName,
241 Prefix: "folder/",
242 });
243 expect(prefixResult.Contents!.length).toBe(1);
244 expect(prefixResult.Contents![0].Key).toBe("folder/file3.txt");
245
246 const limitResult = yield* S3.listObjectsV2({
247 Bucket: bucketName,
248 MaxKeys: 1,
249 });
250 expect(limitResult.Contents!.length).toBe(1);
251 expect(limitResult.IsTruncated).toBe(true);
252
253 yield* stack.destroy();
254 yield* assertBucketDeleted(bucketName);
255 }),
256 { tags: ["provider:aws", "provider:aws:s3", "live"] },
257);
258
259test.provider(
260 "headObject - get object metadata",
261 (stack) =>
262 Effect.gen(function* () {
263 const bucket = yield* deployTestBucket(stack);
264 const bucketName = bucket.bucketName;
265
266 yield* S3.putObject({
267 Bucket: bucketName,
268 Key: "test-file.txt",
269 Body: "Hello, World!",
270 ContentType: "text/plain",
271 });
272
273 const result = yield* S3.headObject({
274 Bucket: bucketName,
275 Key: "test-file.txt",
276 });
277
278 expect(result.ContentType).toBe("text/plain");
279 expect(result.ContentLength).toBe(13);
280 expect(result.ETag).toBeDefined();
281
282 yield* stack.destroy();
283 yield* assertBucketDeleted(bucketName);
284 }),
285 { tags: ["provider:aws", "provider:aws:s3", "live"] },
286);
287
288test.provider(
289 "headObject - returns error for non-existent object",
290 (stack) =>
291 Effect.gen(function* () {
292 const bucket = yield* deployTestBucket(stack);
293 const bucketName = bucket.bucketName;
294
295 const result = yield* S3.headObject({
296 Bucket: bucketName,
297 Key: "non-existent.txt",
298 }).pipe(
299 Effect.map(() => "found" as const),
300 Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
301 );
302
303 expect(result).toBe("not-found");
304
305 yield* stack.destroy();
306 yield* assertBucketDeleted(bucketName);
307 }),
308 { tags: ["provider:aws", "provider:aws:s3", "live"] },
309);
310
311test.provider(
312 "copyObject - copy object within bucket",
313 (stack) =>
314 Effect.gen(function* () {
315 const bucket = yield* deployTestBucket(stack);
316 const bucketName = bucket.bucketName;
317
318 yield* S3.putObject({
319 Bucket: bucketName,
320 Key: "source.txt",
321 Body: "Original content",
322 ContentType: "text/plain",
323 });
324
325 yield* S3.copyObject({
326 Bucket: bucketName,
327 Key: "destination.txt",
328 CopySource: `${bucketName}/source.txt`,
329 });
330
331 const destHead = yield* S3.headObject({
332 Bucket: bucketName,
333 Key: "destination.txt",
334 });
335 expect(destHead.ContentType).toBe("text/plain");
336 expect(destHead.ContentLength).toBe(16);
337
338 const sourceHead = yield* S3.headObject({
339 Bucket: bucketName,
340 Key: "source.txt",
341 });
342 expect(sourceHead.ContentLength).toBe(16);
343
344 yield* stack.destroy();
345 yield* assertBucketDeleted(bucketName);
346 }),
347 { tags: ["provider:aws", "provider:aws:s3", "live"] },
348);
349
350test.provider(
351 "copyObject - copy with metadata replacement",
352 (stack) =>
353 Effect.gen(function* () {
354 const bucket = yield* deployTestBucket(stack);
355 const bucketName = bucket.bucketName;
356
357 yield* S3.putObject({
358 Bucket: bucketName,
359 Key: "source.txt",
360 Body: "Content",
361 ContentType: "text/plain",
362 });
363
364 yield* S3.copyObject({
365 Bucket: bucketName,
366 Key: "destination.txt",
367 CopySource: `${bucketName}/source.txt`,
368 ContentType: "application/octet-stream",
369 MetadataDirective: "REPLACE",
370 });
371
372 const destHead = yield* S3.headObject({
373 Bucket: bucketName,
374 Key: "destination.txt",
375 });
376 expect(destHead.ContentType).toBe("application/octet-stream");
377
378 yield* stack.destroy();
379 yield* assertBucketDeleted(bucketName);
380 }),
381 { tags: ["provider:aws", "provider:aws:s3", "live"] },
382);
383
384test.provider(
385 "multipart upload - complete workflow",
386 (stack) =>
387 Effect.gen(function* () {
388 const bucket = yield* deployTestBucket(stack);
389 const bucketName = bucket.bucketName;
390
391 const createResult = yield* S3.createMultipartUpload({
392 Bucket: bucketName,
393 Key: "multipart-file.txt",
394 ContentType: "text/plain",
395 });
396
397 expect(createResult.UploadId).toBeDefined();
398 const uploadId = createResult.UploadId!;
399
400 // AWS S3 requires parts to be at least 5MB except for the last (or only)
401 // part, so a single-part upload works with any size
402 const partContent = "Complete multipart upload content";
403
404 const partResult = yield* S3.uploadPart({
405 Bucket: bucketName,
406 Key: "multipart-file.txt",
407 UploadId: uploadId,
408 PartNumber: 1,
409 Body: partContent,
410 });
411 expect(partResult.ETag).toBeDefined();
412
413 yield* S3.completeMultipartUpload({
414 Bucket: bucketName,
415 Key: "multipart-file.txt",
416 UploadId: uploadId,
417 MultipartUpload: {
418 Parts: [{ ETag: partResult.ETag!, PartNumber: 1 }],
419 },
420 });
421
422 const headResult = yield* S3.headObject({
423 Bucket: bucketName,
424 Key: "multipart-file.txt",
425 });
426 // AWS S3 may use binary/octet-stream for multipart uploads even when
427 // ContentType is set on createMultipartUpload
428 expect(headResult.ContentLength).toBe(partContent.length);
429
430 yield* stack.destroy();
431 yield* assertBucketDeleted(bucketName);
432 }),
433 { tags: ["provider:aws", "provider:aws:s3", "live"] },
434);
435
436test.provider(
437 "multipart upload - abort",
438 (stack) =>
439 Effect.gen(function* () {
440 const bucket = yield* deployTestBucket(stack);
441 const bucketName = bucket.bucketName;
442
443 const createResult = yield* S3.createMultipartUpload({
444 Bucket: bucketName,
445 Key: "aborted-file.txt",
446 ContentType: "text/plain",
447 });
448
449 const uploadId = createResult.UploadId!;
450
451 yield* S3.uploadPart({
452 Bucket: bucketName,
453 Key: "aborted-file.txt",
454 UploadId: uploadId,
455 PartNumber: 1,
456 Body: "Some content",
457 });
458
459 yield* S3.abortMultipartUpload({
460 Bucket: bucketName,
461 Key: "aborted-file.txt",
462 UploadId: uploadId,
463 });
464
465 const headResult = yield* S3.headObject({
466 Bucket: bucketName,
467 Key: "aborted-file.txt",
468 }).pipe(
469 Effect.map(() => "found" as const),
470 Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
471 );
472
473 expect(headResult).toBe("not-found");
474
475 yield* stack.destroy();
476 yield* assertBucketDeleted(bucketName);
477 }),
478 { tags: ["provider:aws", "provider:aws:s3", "live"] },
479);
480
481test.provider(
482 "putObject and getObject - basic operations",
483 (stack) =>
484 Effect.gen(function* () {
485 const bucket = yield* deployTestBucket(stack);
486 const bucketName = bucket.bucketName;
487
488 yield* S3.putObject({
489 Bucket: bucketName,
490 Key: "test-put.txt",
491 Body: "Test content for put operation",
492 ContentType: "text/plain",
493 });
494
495 const headResult = yield* S3.headObject({
496 Bucket: bucketName,
497 Key: "test-put.txt",
498 });
499 expect(headResult.ContentType).toBe("text/plain");
500 expect(headResult.ContentLength).toBe(30);
501
502 const getResult = yield* S3.getObject({
503 Bucket: bucketName,
504 Key: "test-put.txt",
505 });
506 expect(getResult.ContentType).toBe("text/plain");
507 expect(getResult.ContentLength).toBe(30);
508
509 yield* stack.destroy();
510 yield* assertBucketDeleted(bucketName);
511 }),
512 { tags: ["provider:aws", "provider:aws:s3", "live"] },
513);
514
515test.provider(
516 "deleteObject - remove object",
517 (stack) =>
518 Effect.gen(function* () {
519 const bucket = yield* deployTestBucket(stack);
520 const bucketName = bucket.bucketName;
521
522 yield* S3.putObject({
523 Bucket: bucketName,
524 Key: "to-delete.txt",
525 Body: "Delete me",
526 });
527
528 yield* S3.headObject({
529 Bucket: bucketName,
530 Key: "to-delete.txt",
531 });
532
533 yield* S3.deleteObject({
534 Bucket: bucketName,
535 Key: "to-delete.txt",
536 });
537
538 const headResult = yield* S3.headObject({
539 Bucket: bucketName,
540 Key: "to-delete.txt",
541 }).pipe(
542 Effect.map(() => "found" as const),
543 Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
544 );
545
546 expect(headResult).toBe("not-found");
547
548 yield* stack.destroy();
549 yield* assertBucketDeleted(bucketName);
550 }),
551 { tags: ["provider:aws", "provider:aws:s3", "live"] },
552);
553
554class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
555
556const assertBucketDeleted = Effect.fn(function* (bucketName: string) {
557 yield* S3.headBucket({ Bucket: bucketName }).pipe(
558 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
559 Effect.retry({
560 while: (e) => e._tag === "BucketStillExists",
561 schedule: Schedule.spaced("2 seconds"),
562 times: 9,
563 }),
564 Effect.catchTag("NotFound", () => Effect.void),
565 );
566});