| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Output from "@/Output"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as s3files from "@distilled.cloud/aws/s3files"; |
| 5 | import { expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | |
| 9 | const { test } = Test.make({ providers: AWS.providers() }); |
| 10 | |
| 11 | const 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. |
| 17 | test.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). |
| 31 | test.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 |
| 44 | const 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 | |
| 55 | const 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 | |
| 66 | test.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 | ); |