1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as backup from "@distilled.cloud/aws/backup";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import BackupTestFunctionLive, {
12 BackupTestFunction,
13 FIXTURE_VAULT_NAME,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "BackupBindings");
19
20// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
21// over 60s on a fresh deploy under parallel-suite load.
22const readinessPolicy = Schedule.max([
23 Schedule.fixed("2 seconds"),
24 Schedule.recurs(75),
25]);
26
27let baseUrl: string;
28
29class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
30 readonly status: number;
31 readonly body: string;
32}> {}
33
34// The shared Lambda fixture occasionally answers a transient 5xx under
35// full-suite parallel load (cold re-init, IAM propagation on the freshly
36// attached backup policy that the handler's `Effect.orDie` surfaces as a
37// 500). Retry only 5xx; a genuine 4xx/assertion failure surfaces
38// immediately.
39const send = (request: HttpClientRequest.HttpClientRequest) =>
40 HttpClient.execute(request).pipe(
41 Effect.flatMap((response) =>
42 response.status >= 500
43 ? response.text.pipe(
44 Effect.flatMap((body) =>
45 Effect.fail(
46 new TransientUpstream({ status: response.status, body }),
47 ),
48 ),
49 )
50 : Effect.succeed(response),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "TransientUpstream",
54 schedule: Schedule.max([
55 Schedule.exponential("500 millis"),
56 Schedule.recurs(6),
57 ]),
58 }),
59 );
60
61describe.sequential(
62 "Backup Bindings",
63 {
64 tags: [
65 "provider:aws",
66 "provider:aws:backup",
67 "provider:aws:iam",
68 "provider:aws:lambda",
69 "live",
70 ],
71 },
72 () => {
73 beforeAll(
74 Effect.gen(function* () {
75 yield* Effect.logInfo(
76 "Backup test setup: destroying previous resources",
77 );
78 yield* sharedStack.destroy();
79
80 yield* Effect.logInfo("Backup test setup: deploying fixture");
81 const { functionUrl } = yield* sharedStack.deploy(
82 Effect.gen(function* () {
83 return yield* BackupTestFunction;
84 }).pipe(Effect.provide(BackupTestFunctionLive)),
85 );
86
87 expect(functionUrl).toBeTruthy();
88 baseUrl = functionUrl!.replace(/\/+$/, "");
89
90 const readinessUrl = `${baseUrl}/bindings`;
91 yield* Effect.logInfo(
92 `Backup test setup: probing readiness at ${readinessUrl}`,
93 );
94 yield* HttpClient.get(readinessUrl).pipe(
95 Effect.flatMap((response) =>
96 response.status === 200
97 ? Effect.succeed(response)
98 : Effect.fail(
99 new Error(`Function not ready: ${response.status}`),
100 ),
101 ),
102 Effect.tapError((error) =>
103 Effect.logWarning(
104 `Backup test setup: fixture not ready yet (${String(error)})`,
105 ),
106 ),
107 Effect.retry({ schedule: readinessPolicy }),
108 );
109 }),
110 { timeout: 240_000 },
111 );
112
113 afterAll(
114 Effect.gen(function* () {
115 yield* sharedStack.destroy();
116 // Out-of-band proof the fixture vault is gone (AWS Backup reports a
117 // missing vault as AccessDeniedException, not ResourceNotFoundException).
118 // The raw afterAll context has no providers layer (only `test.provider`
119 // bodies do), so the distilled call must be wrapped in `withProviders`
120 // to satisfy AWS credentials.
121 const vault = yield* Core.withProviders(
122 backup
123 .describeBackupVault({ BackupVaultName: FIXTURE_VAULT_NAME })
124 .pipe(
125 Effect.catchTag(
126 ["ResourceNotFoundException", "AccessDeniedException"],
127 () => Effect.succeed(undefined),
128 ),
129 ),
130 testOptions,
131 "BackupBindings",
132 );
133 expect(vault).toBeUndefined();
134 }),
135 { timeout: 120_000 },
136 );
137
138 describe("binding registration", () => {
139 test.provider("all 20 capabilities initialize in the runtime", (_stack) =>
140 Effect.gen(function* () {
141 const response = yield* send(
142 HttpClientRequest.get(`${baseUrl}/bindings`),
143 ).pipe(Effect.flatMap((r) => r.json));
144 expect((response as any).bound).toHaveLength(20);
145 }),
146 );
147 });
148
149 describe("ListRecoveryPointsByBackupVault", () => {
150 test.provider("lists the fixture vault's recovery points", (_stack) =>
151 Effect.gen(function* () {
152 const response = yield* send(
153 HttpClientRequest.get(`${baseUrl}/list-recovery-points`),
154 ).pipe(Effect.flatMap((r) => r.json));
155 // A fresh vault has zero recovery points — assert the call
156 // round-trips (BackupVaultName injection + vault-scoped IAM).
157 expect((response as any).count).toBe(0);
158 }),
159 );
160 });
161
162 describe("ListBackupJobs", () => {
163 test.provider("lists the account's backup jobs", (_stack) =>
164 Effect.gen(function* () {
165 const response = yield* send(
166 HttpClientRequest.get(`${baseUrl}/list-backup-jobs`),
167 ).pipe(Effect.flatMap((r) => r.json));
168 expect(typeof (response as any).count).toBe("number");
169 }),
170 );
171 });
172
173 describe("ListRestoreJobs", () => {
174 test.provider("lists the account's restore jobs", (_stack) =>
175 Effect.gen(function* () {
176 const response = yield* send(
177 HttpClientRequest.get(`${baseUrl}/list-restore-jobs`),
178 ).pipe(Effect.flatMap((r) => r.json));
179 expect(typeof (response as any).count).toBe("number");
180 }),
181 );
182 });
183
184 describe("ListCopyJobs", () => {
185 test.provider("lists the account's copy jobs", (_stack) =>
186 Effect.gen(function* () {
187 const response = yield* send(
188 HttpClientRequest.get(`${baseUrl}/list-copy-jobs`),
189 ).pipe(Effect.flatMap((r) => r.json));
190 expect(typeof (response as any).count).toBe("number");
191 }),
192 );
193 });
194
195 describe("ListProtectedResources", () => {
196 test.provider("lists the account's protected resources", (_stack) =>
197 Effect.gen(function* () {
198 const response = yield* send(
199 HttpClientRequest.get(`${baseUrl}/list-protected-resources`),
200 ).pipe(Effect.flatMap((r) => r.json));
201 expect(typeof (response as any).count).toBe("number");
202 }),
203 );
204 });
205
206 describe("GetSupportedResourceTypes", () => {
207 test.provider("returns the supported resource types", (_stack) =>
208 Effect.gen(function* () {
209 const response = yield* send(
210 HttpClientRequest.get(`${baseUrl}/supported-resource-types`),
211 ).pipe(Effect.flatMap((r) => r.json));
212 expect((response as any).resourceTypes).toContain("DynamoDB");
213 }),
214 );
215 });
216
217 describe("DescribeBackupJob", () => {
218 test.provider(
219 "returns the typed not-found error for an unknown job id",
220 (_stack) =>
221 Effect.gen(function* () {
222 const response = yield* send(
223 HttpClientRequest.get(`${baseUrl}/describe-backup-job-not-found`),
224 ).pipe(Effect.flatMap((r) => r.json));
225 expect((response as any).found).toBe(false);
226 }),
227 );
228 });
229
230 describe("DescribeRecoveryPoint", () => {
231 test.provider(
232 "returns the typed not-found error for an unknown recovery point",
233 (_stack) =>
234 Effect.gen(function* () {
235 const response = yield* send(
236 HttpClientRequest.get(
237 `${baseUrl}/describe-recovery-point-not-found`,
238 ),
239 ).pipe(Effect.flatMap((r) => r.json));
240 expect((response as any).found).toBe(false);
241 }),
242 );
243 });
244
245 describe("GetRecoveryPointRestoreMetadata", () => {
246 test.provider(
247 "returns the typed not-found error for an unknown recovery point",
248 (_stack) =>
249 Effect.gen(function* () {
250 const response = yield* send(
251 HttpClientRequest.get(`${baseUrl}/restore-metadata-not-found`),
252 ).pipe(Effect.flatMap((r) => r.json));
253 expect((response as any).found).toBe(false);
254 }),
255 );
256 });
257
258 describe("GetRestoreJobMetadata", () => {
259 test.provider(
260 "returns the typed not-found error for an unknown restore job",
261 (_stack) =>
262 Effect.gen(function* () {
263 const response = yield* send(
264 HttpClientRequest.get(
265 `${baseUrl}/restore-job-metadata-not-found`,
266 ),
267 ).pipe(Effect.flatMap((r) => r.json));
268 expect((response as any).found).toBe(false);
269 }),
270 );
271 });
272
273 describe("PutRestoreValidationResult", () => {
274 // A verdict can only be posted against a live restore-test job (minutes
275 // of provisioning); the fixture drives the binding through its typed
276 // error path — an IAM gap would surface AccessDeniedException (500)
277 // instead of a typed tag.
278 test.provider(
279 "surfaces a typed error for an unknown restore job",
280 (_stack) =>
281 Effect.gen(function* () {
282 const response = yield* send(
283 HttpClientRequest.post(
284 `${baseUrl}/put-restore-validation-not-found`,
285 ),
286 ).pipe(Effect.flatMap((r) => r.json));
287 expect([
288 "ResourceNotFoundException",
289 "InvalidParameterValueException",
290 "MissingParameterValueException",
291 "InvalidRequestException",
292 ]).toContain((response as any).tag);
293 }),
294 );
295 });
296
297 describe("StopBackupJob", () => {
298 test.provider("surfaces a typed error for an unknown job id", (_stack) =>
299 Effect.gen(function* () {
300 const response = yield* send(
301 HttpClientRequest.post(`${baseUrl}/stop-backup-job-not-found`),
302 ).pipe(Effect.flatMap((r) => r.json));
303 expect([
304 "ResourceNotFoundException",
305 "InvalidParameterValueException",
306 "InvalidRequestException",
307 ]).toContain((response as any).tag);
308 }),
309 );
310 });
311
312 describe("StartRestoreJob", () => {
313 // A real restore needs a live recovery point (a completed backup job —
314 // minutes of provisioning). The fixture drives the binding through its
315 // typed error path: role injection + backup:StartRestoreJob +
316 // iam:PassRole are all exercised, and an IAM gap would surface
317 // AccessDeniedException (a 500) instead of the typed tag.
318 test.provider(
319 "surfaces a typed error for an unknown recovery point",
320 (_stack) =>
321 Effect.gen(function* () {
322 const response = yield* send(
323 HttpClientRequest.post(`${baseUrl}/start-restore-not-found`),
324 ).pipe(Effect.flatMap((r) => r.json));
325 expect([
326 "ResourceNotFoundException",
327 "InvalidParameterValueException",
328 "MissingParameterValueException",
329 "InvalidRequestException",
330 ]).toContain((response as any).tag);
331 }),
332 );
333 });
334 },
335);