1import * as AWS from "@/AWS";
2import * as Output from "@/Output";
3import * as Test from "@/Test/Alchemy";
4import * as s3files from "@distilled.cloud/aws/s3files";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11const tagRecord = (tags: readonly s3files.Tag[] | undefined) =>
12 Object.fromEntries((tags ?? []).map((t) => [t.key, t.value]));
13
14// Ungated typed-error probe: prove the distilled error union carries the
15// not-found tag this provider's observe/read/delete paths depend on. Runs
16// in every CI pass at near-zero cost, unlike the gated lifecycle below.
17test.provider(
18 "getFileSystem on a nonexistent file system fails with ResourceNotFoundException",
19 () =>
20 Effect.gen(function* () {
21 const error = yield* Effect.flip(
22 s3files.getFileSystem({ fileSystemId: "fs-0123456789abcdef0" }),
23 );
24 expect(error._tag).toBe("ResourceNotFoundException");
25 }),
26 { tags: ["provider:aws", "provider:aws:s3files", "live"], timeout: 60_000 },
27);
28
29// Ungated reachability probe: the account can enumerate file systems in the
30// test region (proves endpoint + auth + response decoding).
31test.provider(
32 "listFileSystems succeeds",
33 () =>
34 Effect.gen(function* () {
35 const listed = yield* s3files.listFileSystems({ maxResults: 5 });
36 expect(Array.isArray(listed.fileSystems)).toBe(true);
37 }),
38 { tags: ["provider:aws", "provider:aws:s3files", "live"], timeout: 60_000 },
39);
40
41// S3 Files runs on EFS infrastructure — the file system role is assumed by
42// the elasticfilesystem service principal, not an s3files one.
43// https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files-prereq-policies.html
44const trustPolicy = {
45 Version: "2012-10-17" as const,
46 Statement: [
47 {
48 Effect: "Allow" as const,
49 Principal: { Service: "elasticfilesystem.amazonaws.com" },
50 Action: ["sts:AssumeRole"],
51 },
52 ],
53};
54
55const waitUntilFileSystemGone = (fileSystemId: string) =>
56 s3files.getFileSystem({ fileSystemId }).pipe(
57 Effect.as(false),
58 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(true)),
59 Effect.repeat({
60 schedule: Schedule.fixed("5 seconds"),
61 until: (gone) => gone,
62 times: 24,
63 }),
64 );
65
66test.provider(
67 "create file system + access point, verify, destroy",
68 (stack) =>
69 Effect.gen(function* () {
70 yield* stack.destroy();
71
72 const created = yield* stack.deploy(
73 Effect.gen(function* () {
74 // S3 Files requires versioning on the source bucket.
75 const bucket = yield* AWS.S3.Bucket("FilesBucket", {
76 forceDestroy: true,
77 versioning: "Enabled",
78 });
79 const role = yield* AWS.IAM.Role("FilesRole", {
80 assumeRolePolicyDocument: trustPolicy,
81 inlinePolicies: {
82 S3FilesBucketAccess: {
83 Version: "2012-10-17",
84 Statement: [
85 {
86 Effect: "Allow",
87 Action: ["s3:ListBucket", "s3:ListBucketVersions"],
88 Resource: [bucket.bucketArn],
89 },
90 {
91 Effect: "Allow",
92 Action: [
93 "s3:GetObject*",
94 "s3:PutObject*",
95 "s3:DeleteObject*",
96 "s3:List*",
97 "s3:AbortMultipartUpload",
98 ],
99 Resource: [Output.interpolate`${bucket.bucketArn}/*`],
100 },
101 {
102 Effect: "Allow",
103 Action: [
104 "events:DeleteRule",
105 "events:DisableRule",
106 "events:EnableRule",
107 "events:PutRule",
108 "events:PutTargets",
109 "events:RemoveTargets",
110 ],
111 Resource: [
112 "arn:aws:events:*:*:rule/DO-NOT-DELETE-S3-Files*",
113 ],
114 },
115 {
116 Effect: "Allow",
117 Action: [
118 "events:DescribeRule",
119 "events:ListRuleNamesByTarget",
120 "events:ListRules",
121 "events:ListTargetsByRule",
122 ],
123 Resource: ["arn:aws:events:*:*:rule/*"],
124 },
125 ],
126 },
127 },
128 });
129 const fileSystem = yield* AWS.S3Files.FileSystem("Files", {
130 bucket: bucket.bucketArn,
131 roleArn: role.roleArn,
132 tags: { fixture: "s3files-filesystem" },
133 });
134 const accessPoint = yield* AWS.S3Files.AccessPoint("Access", {
135 fileSystemId: fileSystem.fileSystemId,
136 posixUser: { uid: 1000, gid: 1000 },
137 rootDirectory: {
138 path: "/app",
139 creationPermissions: {
140 ownerUid: 1000,
141 ownerGid: 1000,
142 permissions: "0755",
143 },
144 },
145 });
146 return { fileSystem, accessPoint };
147 }),
148 );
149
150 expect(created.fileSystem.fileSystemId).toContain("fs-");
151 expect(created.fileSystem.fileSystemArn).toContain(":file-system/");
152 expect(created.fileSystem.status).toBe("available");
153 expect(created.accessPoint.fileSystemId).toBe(
154 created.fileSystem.fileSystemId,
155 );
156 expect(created.accessPoint.status).toBe("available");
157
158 // Out-of-band verification via distilled.
159 const observed = yield* s3files.getFileSystem({
160 fileSystemId: created.fileSystem.fileSystemId,
161 });
162 expect(observed.status).toBe("available");
163 expect(observed.bucket).toBe(created.fileSystem.bucket);
164 const tags = tagRecord(observed.tags);
165 expect(tags.fixture).toBe("s3files-filesystem");
166 expect(tags["alchemy::id"]).toBe("Files");
167
168 const observedAp = yield* s3files.getAccessPoint({
169 accessPointId: created.accessPoint.accessPointId,
170 });
171 expect(observedAp.posixUser?.uid).toBe(1000);
172 expect(observedAp.rootDirectory?.path).toBe("/app");
173
174 // Destroy and verify the file system is gone out-of-band.
175 yield* stack.destroy();
176 const gone = yield* waitUntilFileSystemGone(
177 created.fileSystem.fileSystemId,
178 );
179 expect(gone).toBe(true);
180 }),
181 {
182 tags: [
183 "provider:aws",
184 "provider:aws:iam",
185 "provider:aws:s3",
186 "provider:aws:s3files",
187 "live",
188 ],
189 timeout: 600_000,
190 },
191);