1import * as Backup from "@/AWS/Backup";
2import * as IAM from "@/AWS/IAM";
3import * as Lambda from "@/AWS/Lambda";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13// Deterministic fixture names — distinct from the lifecycle suite's vault so
14// the two test files stay quota- and state-independent.
15export const FIXTURE_VAULT_NAME = "alchemy-test-backup-bindings-vault";
16
17// Well-formed-but-nonexistent identifiers used to drive typed error paths.
18// An IAM gap would surface AccessDeniedException (a 500 through the
19// handler's orDie), so a typed not-found tag proves the grant end-to-end.
20const BOGUS_JOB_ID = "00000000-0000-0000-0000-000000000000";
21const BOGUS_RECOVERY_POINT_ARN =
22 "arn:aws:ec2:us-east-1::snapshot/snap-00000000000000000";
23
24export class BackupTestFunction extends Lambda.Function<Lambda.Function>()(
25 "BackupTestFunction",
26) {}
27
28export default BackupTestFunction.make(
29 {
30 main,
31 functionUrl: true,
32 },
33 Effect.gen(function* () {
34 const vault = yield* Backup.BackupVault("BindingsVault", {
35 backupVaultName: FIXTURE_VAULT_NAME,
36 });
37 // The role AWS Backup assumes for backup/copy/restore jobs started by
38 // the runtime bindings.
39 const backupRole = yield* IAM.Role("BindingsBackupRole", {
40 assumeRolePolicyDocument: {
41 Version: "2012-10-17",
42 Statement: [
43 {
44 Effect: "Allow",
45 Principal: { Service: "backup.amazonaws.com" },
46 Action: ["sts:AssumeRole"],
47 },
48 ],
49 },
50 managedPolicyArns: [
51 "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup",
52 "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores",
53 ],
54 });
55
56 // --- vault-scoped bindings ---
57 const listRecoveryPointsByBackupVault =
58 yield* Backup.ListRecoveryPointsByBackupVault(vault);
59 const describeRecoveryPoint = yield* Backup.DescribeRecoveryPoint(vault);
60 const getRecoveryPointRestoreMetadata =
61 yield* Backup.GetRecoveryPointRestoreMetadata(vault);
62 // Bound (compile + IAM coverage) but not routed: needs a live recovery
63 // point to delete deterministically.
64 const deleteRecoveryPoint = yield* Backup.DeleteRecoveryPoint(vault);
65
66 // --- job-start bindings (vault/role injection) ---
67 // Bound but not routed to a live start: an on-demand job takes minutes
68 // and burns storage; StartRestoreJob is driven through its typed
69 // not-found path instead.
70 const startBackupJob = yield* Backup.StartBackupJob(vault, backupRole);
71 const startCopyJob = yield* Backup.StartCopyJob(vault, backupRole);
72 const startRestoreJob = yield* Backup.StartRestoreJob(backupRole);
73
74 // --- account-level bindings ---
75 const describeBackupJob = yield* Backup.DescribeBackupJob();
76 const listBackupJobs = yield* Backup.ListBackupJobs();
77 const stopBackupJob = yield* Backup.StopBackupJob();
78 const describeRestoreJob = yield* Backup.DescribeRestoreJob();
79 const listRestoreJobs = yield* Backup.ListRestoreJobs();
80 // Restore-testing validation flow — the canonical Backup runtime use:
81 // AWS invokes a validation Lambda after a restore test; it inspects the
82 // restored resource and reports the verdict.
83 const getRestoreJobMetadata = yield* Backup.GetRestoreJobMetadata();
84 const putRestoreValidationResult =
85 yield* Backup.PutRestoreValidationResult();
86 const describeCopyJob = yield* Backup.DescribeCopyJob();
87 const listCopyJobs = yield* Backup.ListCopyJobs();
88 const listProtectedResources = yield* Backup.ListProtectedResources();
89 const describeProtectedResource = yield* Backup.DescribeProtectedResource();
90 const getSupportedResourceTypes = yield* Backup.GetSupportedResourceTypes();
91 const listRecoveryPointsByResource =
92 yield* Backup.ListRecoveryPointsByResource();
93
94 // --- event source ---
95 // Deploy-time: creates the EventBridge rule (default bus, source
96 // aws.backup) targeting this Function. Runtime firing needs a real
97 // backup job, so the test only verifies the subscription deploys.
98 yield* Backup.consumeBackupEvents(
99 { kinds: ["backup-job", "restore-job"] },
100 (events) =>
101 Stream.runForEach(events, (event) =>
102 Effect.log(`backup event: ${event.detail.state}`),
103 ),
104 );
105
106 const bound = {
107 listRecoveryPointsByBackupVault,
108 describeRecoveryPoint,
109 deleteRecoveryPoint,
110 getRecoveryPointRestoreMetadata,
111 startBackupJob,
112 startCopyJob,
113 startRestoreJob,
114 describeBackupJob,
115 listBackupJobs,
116 stopBackupJob,
117 describeRestoreJob,
118 listRestoreJobs,
119 getRestoreJobMetadata,
120 putRestoreValidationResult,
121 describeCopyJob,
122 listCopyJobs,
123 listProtectedResources,
124 describeProtectedResource,
125 getSupportedResourceTypes,
126 listRecoveryPointsByResource,
127 };
128
129 return {
130 fetch: Effect.gen(function* () {
131 const request = yield* HttpServerRequest;
132 const url = new URL(request.originalUrl);
133 const pathname = url.pathname;
134
135 if (request.method === "GET" && pathname === "/bindings") {
136 return yield* HttpServerResponse.json({
137 bound: Object.keys(bound),
138 });
139 }
140
141 if (request.method === "GET" && pathname === "/list-recovery-points") {
142 const result = yield* listRecoveryPointsByBackupVault({
143 MaxResults: 25,
144 });
145 return yield* HttpServerResponse.json({
146 count: (result.RecoveryPoints ?? []).length,
147 });
148 }
149
150 if (request.method === "GET" && pathname === "/list-backup-jobs") {
151 const result = yield* listBackupJobs({ MaxResults: 25 });
152 return yield* HttpServerResponse.json({
153 count: (result.BackupJobs ?? []).length,
154 });
155 }
156
157 if (request.method === "GET" && pathname === "/list-restore-jobs") {
158 const result = yield* listRestoreJobs({ MaxResults: 25 });
159 return yield* HttpServerResponse.json({
160 count: (result.RestoreJobs ?? []).length,
161 });
162 }
163
164 if (request.method === "GET" && pathname === "/list-copy-jobs") {
165 const result = yield* listCopyJobs({ MaxResults: 25 });
166 return yield* HttpServerResponse.json({
167 count: (result.CopyJobs ?? []).length,
168 });
169 }
170
171 if (
172 request.method === "GET" &&
173 pathname === "/list-protected-resources"
174 ) {
175 const result = yield* listProtectedResources({ MaxResults: 25 });
176 return yield* HttpServerResponse.json({
177 count: (result.Results ?? []).length,
178 });
179 }
180
181 if (
182 request.method === "GET" &&
183 pathname === "/supported-resource-types"
184 ) {
185 const result = yield* getSupportedResourceTypes();
186 return yield* HttpServerResponse.json({
187 resourceTypes: result.ResourceTypes ?? [],
188 });
189 }
190
191 if (
192 request.method === "GET" &&
193 pathname === "/describe-backup-job-not-found"
194 ) {
195 // Exercises the typed not-found error path end-to-end.
196 const result = yield* describeBackupJob({
197 BackupJobId: BOGUS_JOB_ID,
198 }).pipe(
199 Effect.map(() => ({ found: true })),
200 Effect.catchTag(
201 ["ResourceNotFoundException", "InvalidParameterValueException"],
202 () => Effect.succeed({ found: false }),
203 ),
204 );
205 return yield* HttpServerResponse.json(result);
206 }
207
208 if (
209 request.method === "GET" &&
210 pathname === "/describe-recovery-point-not-found"
211 ) {
212 // Exercises BackupVaultName injection + the typed not-found path.
213 const result = yield* describeRecoveryPoint({
214 RecoveryPointArn: BOGUS_RECOVERY_POINT_ARN,
215 }).pipe(
216 Effect.map(() => ({ found: true })),
217 Effect.catchTag(
218 ["ResourceNotFoundException", "InvalidParameterValueException"],
219 () => Effect.succeed({ found: false }),
220 ),
221 );
222 return yield* HttpServerResponse.json(result);
223 }
224
225 if (
226 request.method === "GET" &&
227 pathname === "/restore-metadata-not-found"
228 ) {
229 const result = yield* getRecoveryPointRestoreMetadata({
230 RecoveryPointArn: BOGUS_RECOVERY_POINT_ARN,
231 }).pipe(
232 Effect.map(() => ({ found: true })),
233 Effect.catchTag(
234 ["ResourceNotFoundException", "InvalidParameterValueException"],
235 () => Effect.succeed({ found: false }),
236 ),
237 );
238 return yield* HttpServerResponse.json(result);
239 }
240
241 if (
242 request.method === "GET" &&
243 pathname === "/restore-job-metadata-not-found"
244 ) {
245 const result = yield* getRestoreJobMetadata({
246 RestoreJobId: BOGUS_JOB_ID,
247 }).pipe(
248 Effect.map(() => ({ found: true })),
249 Effect.catchTag(
250 ["ResourceNotFoundException", "InvalidParameterValueException"],
251 () => Effect.succeed({ found: false }),
252 ),
253 );
254 return yield* HttpServerResponse.json(result);
255 }
256
257 if (
258 request.method === "POST" &&
259 pathname === "/put-restore-validation-not-found"
260 ) {
261 // A verdict can only be posted against a live restore-test job;
262 // drive the binding through its typed error path instead — an IAM
263 // gap would surface AccessDeniedException (500), not a typed tag.
264 const tag = yield* putRestoreValidationResult({
265 RestoreJobId: BOGUS_JOB_ID,
266 ValidationStatus: "SUCCESSFUL",
267 }).pipe(
268 Effect.map(() => "Accepted"),
269 Effect.catchTag(
270 [
271 "ResourceNotFoundException",
272 "InvalidParameterValueException",
273 "MissingParameterValueException",
274 "InvalidRequestException",
275 ],
276 (e) => Effect.succeed(e._tag),
277 ),
278 );
279 return yield* HttpServerResponse.json({ tag });
280 }
281
282 if (
283 request.method === "POST" &&
284 pathname === "/stop-backup-job-not-found"
285 ) {
286 const tag = yield* stopBackupJob({ BackupJobId: BOGUS_JOB_ID }).pipe(
287 Effect.map(() => "Stopped"),
288 Effect.catchTag(
289 [
290 "ResourceNotFoundException",
291 "InvalidParameterValueException",
292 "InvalidRequestException",
293 ],
294 (e) => Effect.succeed(e._tag),
295 ),
296 );
297 return yield* HttpServerResponse.json({ tag });
298 }
299
300 if (
301 request.method === "POST" &&
302 pathname === "/start-restore-not-found"
303 ) {
304 // A real restore needs a live recovery point; drive the binding
305 // (role injection + IAM grant) through its typed error path — an
306 // IAM gap would surface AccessDeniedException (500) instead.
307 const tag = yield* startRestoreJob({
308 RecoveryPointArn: BOGUS_RECOVERY_POINT_ARN,
309 Metadata: {},
310 }).pipe(
311 Effect.map(() => "Started"),
312 Effect.catchTag(
313 [
314 "ResourceNotFoundException",
315 "InvalidParameterValueException",
316 "MissingParameterValueException",
317 "InvalidRequestException",
318 ],
319 (e) => Effect.succeed(e._tag),
320 ),
321 );
322 return yield* HttpServerResponse.json({ tag });
323 }
324
325 return yield* HttpServerResponse.json(
326 { error: "Not found", method: request.method, pathname },
327 { status: 404 },
328 );
329 }).pipe(Effect.orDie),
330 };
331 }).pipe(
332 Effect.provide(
333 Layer.mergeAll(
334 Lambda.EventSource,
335 Backup.ListRecoveryPointsByBackupVaultHttp,
336 Backup.DescribeRecoveryPointHttp,
337 Backup.DeleteRecoveryPointHttp,
338 Backup.GetRecoveryPointRestoreMetadataHttp,
339 Backup.StartBackupJobHttp,
340 Backup.StartCopyJobHttp,
341 Backup.StartRestoreJobHttp,
342 Backup.DescribeBackupJobHttp,
343 Backup.ListBackupJobsHttp,
344 Backup.StopBackupJobHttp,
345 Backup.DescribeRestoreJobHttp,
346 Backup.ListRestoreJobsHttp,
347 Backup.GetRestoreJobMetadataHttp,
348 Backup.PutRestoreValidationResultHttp,
349 Backup.DescribeCopyJobHttp,
350 Backup.ListCopyJobsHttp,
351 Backup.ListProtectedResourcesHttp,
352 Backup.DescribeProtectedResourceHttp,
353 Backup.GetSupportedResourceTypesHttp,
354 Backup.ListRecoveryPointsByResourceHttp,
355 ),
356 ),
357 ),
358);