| 1 | import * as IAM from "@/AWS/IAM"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Transfer from "@/AWS/Transfer"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Redacted from "effect/Redacted"; |
| 8 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 10 | import path from "pathe"; |
| 11 | |
| 12 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 13 | |
| 14 | /** |
| 15 | * Fixture SSH public key — generated once with |
| 16 | * `ssh-keygen -t ed25519` and checked in (public half only). |
| 17 | */ |
| 18 | export const TEST_SSH_PUBLIC_KEY = |
| 19 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDc7/2FDuRZgYQHMZujBoouOVFFp/WhqqbSSJ/+33hq2 alchemy-transfer-test"; |
| 20 | |
| 21 | const trustPolicy = { |
| 22 | Version: "2012-10-17" as const, |
| 23 | Statement: [ |
| 24 | { |
| 25 | Effect: "Allow" as const, |
| 26 | Principal: { Service: "transfer.amazonaws.com" }, |
| 27 | Action: ["sts:AssumeRole"], |
| 28 | }, |
| 29 | ], |
| 30 | }; |
| 31 | |
| 32 | /** |
| 33 | * Full (AWS_TEST_SLOW-gated) fixture: a real SFTP server + service-managed |
| 34 | * user with every server/user-scoped Transfer binding attached. |
| 35 | */ |
| 36 | export class TransferTestFunction extends Lambda.Function<Lambda.Function>()( |
| 37 | "TransferTestFunction", |
| 38 | ) {} |
| 39 | |
| 40 | export default TransferTestFunction.make( |
| 41 | { |
| 42 | main, |
| 43 | functionUrl: true, |
| 44 | timeout: Duration.seconds(60), |
| 45 | }, |
| 46 | Effect.gen(function* () { |
| 47 | const server = yield* Transfer.Server("BindingsServer", { |
| 48 | protocols: ["SFTP"], |
| 49 | domain: "S3", |
| 50 | endpointType: "PUBLIC", |
| 51 | identityProviderType: "SERVICE_MANAGED", |
| 52 | }); |
| 53 | const role = yield* IAM.Role("BindingsUserRole", { |
| 54 | assumeRolePolicyDocument: trustPolicy, |
| 55 | inlinePolicies: { |
| 56 | s3: { |
| 57 | Version: "2012-10-17", |
| 58 | Statement: [ |
| 59 | { |
| 60 | Effect: "Allow", |
| 61 | Action: ["s3:ListBucket", "s3:GetObject", "s3:PutObject"], |
| 62 | Resource: ["*"], |
| 63 | }, |
| 64 | ], |
| 65 | }, |
| 66 | }, |
| 67 | }); |
| 68 | const user = yield* Transfer.User("BindingsUser", { |
| 69 | serverId: server.serverId, |
| 70 | userName: "alice", |
| 71 | role: role.roleArn, |
| 72 | homeDirectory: "/example-bucket/alice", |
| 73 | }); |
| 74 | |
| 75 | const describeServer = yield* Transfer.DescribeServer(server); |
| 76 | const startServer = yield* Transfer.StartServer(server); |
| 77 | const stopServer = yield* Transfer.StopServer(server); |
| 78 | const listUsers = yield* Transfer.ListUsers(server); |
| 79 | const testIdentityProvider = yield* Transfer.TestIdentityProvider(server); |
| 80 | const describeUser = yield* Transfer.DescribeUser(user); |
| 81 | const importSshPublicKey = yield* Transfer.ImportSshPublicKey(user); |
| 82 | const deleteSshPublicKey = yield* Transfer.DeleteSshPublicKey(user); |
| 83 | |
| 84 | const bound = { |
| 85 | describeServer, |
| 86 | startServer, |
| 87 | stopServer, |
| 88 | listUsers, |
| 89 | testIdentityProvider, |
| 90 | describeUser, |
| 91 | importSshPublicKey, |
| 92 | deleteSshPublicKey, |
| 93 | }; |
| 94 | |
| 95 | return { |
| 96 | fetch: Effect.gen(function* () { |
| 97 | const request = yield* HttpServerRequest; |
| 98 | const url = new URL(request.originalUrl); |
| 99 | const pathname = url.pathname; |
| 100 | |
| 101 | if (request.method === "GET" && pathname === "/bindings") { |
| 102 | return yield* HttpServerResponse.json({ |
| 103 | bound: Object.keys(bound), |
| 104 | }); |
| 105 | } |
| 106 | |
| 107 | // Server-scoped read: the ServerId is injected from the binding. |
| 108 | if (request.method === "GET" && pathname === "/server") { |
| 109 | const { Server } = yield* describeServer(); |
| 110 | return yield* HttpServerResponse.json({ |
| 111 | serverId: Server.ServerId, |
| 112 | state: Server.State, |
| 113 | }); |
| 114 | } |
| 115 | |
| 116 | if (request.method === "GET" && pathname === "/users") { |
| 117 | const { Users } = yield* listUsers(); |
| 118 | return yield* HttpServerResponse.json({ |
| 119 | userNames: (Users ?? []).map((u) => u.UserName), |
| 120 | }); |
| 121 | } |
| 122 | |
| 123 | // User-scoped read: ServerId + UserName injected from the binding. |
| 124 | if (request.method === "GET" && pathname === "/user") { |
| 125 | const { User } = yield* describeUser(); |
| 126 | return yield* HttpServerResponse.json({ |
| 127 | userName: User.UserName, |
| 128 | keyIds: (User.SshPublicKeys ?? []).map((k) => k.SshPublicKeyId), |
| 129 | }); |
| 130 | } |
| 131 | |
| 132 | // Key rotation: import the checked-in fixture key. |
| 133 | if (request.method === "POST" && pathname === "/key") { |
| 134 | const imported = yield* importSshPublicKey({ |
| 135 | SshPublicKeyBody: TEST_SSH_PUBLIC_KEY, |
| 136 | }).pipe(Effect.result); |
| 137 | return yield* HttpServerResponse.json( |
| 138 | imported._tag === "Success" |
| 139 | ? { ok: true, keyId: imported.success.SshPublicKeyId } |
| 140 | : { ok: false, tag: imported.failure._tag }, |
| 141 | ); |
| 142 | } |
| 143 | |
| 144 | if (request.method === "DELETE" && pathname === "/key") { |
| 145 | const keyId = url.searchParams.get("id"); |
| 146 | if (keyId === null) { |
| 147 | return yield* HttpServerResponse.json( |
| 148 | { error: "missing id" }, |
| 149 | { status: 400 }, |
| 150 | ); |
| 151 | } |
| 152 | const deleted = yield* deleteSshPublicKey({ |
| 153 | SshPublicKeyId: keyId, |
| 154 | }).pipe(Effect.result); |
| 155 | return yield* HttpServerResponse.json( |
| 156 | deleted._tag === "Success" |
| 157 | ? { ok: true } |
| 158 | : { ok: false, tag: deleted.failure._tag }, |
| 159 | ); |
| 160 | } |
| 161 | |
| 162 | // Availability control: valid only in ONLINE/OFFLINE respectively — |
| 163 | // report the typed rejection so the test can assert the round-trip. |
| 164 | if (request.method === "POST" && pathname === "/stop") { |
| 165 | const stopped = yield* stopServer().pipe(Effect.result); |
| 166 | return yield* HttpServerResponse.json( |
| 167 | stopped._tag === "Success" |
| 168 | ? { ok: true } |
| 169 | : { ok: false, tag: stopped.failure._tag }, |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | if (request.method === "POST" && pathname === "/start") { |
| 174 | const started = yield* startServer().pipe(Effect.result); |
| 175 | return yield* HttpServerResponse.json( |
| 176 | started._tag === "Success" |
| 177 | ? { ok: true } |
| 178 | : { ok: false, tag: started.failure._tag }, |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | // The fixture server is SERVICE_MANAGED, so Transfer rejects the |
| 183 | // identity-provider test with the typed InvalidRequestException — |
| 184 | // which still proves the binding + IAM round-trip. |
| 185 | if (request.method === "POST" && pathname === "/test-idp") { |
| 186 | const tested = yield* testIdentityProvider({ |
| 187 | UserName: "alice", |
| 188 | UserPassword: Redacted.make("not-a-real-password"), |
| 189 | ServerProtocol: "SFTP", |
| 190 | }).pipe(Effect.result); |
| 191 | return yield* HttpServerResponse.json( |
| 192 | tested._tag === "Success" |
| 193 | ? { ok: true, statusCode: tested.success.StatusCode } |
| 194 | : { ok: false, tag: tested.failure._tag }, |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | return yield* HttpServerResponse.json( |
| 199 | { error: "Not found", method: request.method, pathname }, |
| 200 | { status: 404 }, |
| 201 | ); |
| 202 | }).pipe(Effect.orDie), |
| 203 | }; |
| 204 | }).pipe( |
| 205 | Effect.provide( |
| 206 | Layer.mergeAll( |
| 207 | Transfer.DescribeServerHttp, |
| 208 | Transfer.StartServerHttp, |
| 209 | Transfer.StopServerHttp, |
| 210 | Transfer.ListUsersHttp, |
| 211 | Transfer.TestIdentityProviderHttp, |
| 212 | Transfer.DescribeUserHttp, |
| 213 | Transfer.ImportSshPublicKeyHttp, |
| 214 | Transfer.DeleteSshPublicKeyHttp, |
| 215 | ), |
| 216 | ), |
| 217 | ), |
| 218 | ); |