1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as Transfer from "@/AWS/Transfer";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const 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 */
18export const TEST_SSH_PUBLIC_KEY =
19 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDc7/2FDuRZgYQHMZujBoouOVFFp/WhqqbSSJ/+33hq2 alchemy-transfer-test";
20
21const 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 */
36export class TransferTestFunction extends Lambda.Function<Lambda.Function>()(
37 "TransferTestFunction",
38) {}
39
40export 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);