1import * as AWS from "@/AWS";
2import { SearchJob } from "@/AWS/BackupSearch";
3import * as Test from "@/Test/Alchemy";
4import * as backupsearch from "@distilled.cloud/aws/backupsearch";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7
8const { test } = Test.make({ providers: AWS.providers() });
9
10// Ungated typed-error probes: prove the distilled error union carries the
11// not-found tag this provider's read/reconcile/delete paths depend on. Runs
12// in every CI pass at near-zero cost, unlike the gated lifecycle below.
13test.provider(
14 "getSearchJob on a nonexistent identifier fails with ResourceNotFoundException",
15 () =>
16 Effect.gen(function* () {
17 // Identifiers must be UUID-shaped — anything else is a ValidationException.
18 const error = yield* Effect.flip(
19 backupsearch.getSearchJob({
20 SearchJobIdentifier: "00000000-0000-0000-0000-000000000000",
21 }),
22 );
23 expect(error._tag).toBe("ResourceNotFoundException");
24 }),
25 { tags: ["provider:aws", "provider:aws:backupsearch", "live"] },
26);
27
28test.provider(
29 "getSearchResultExportJob on a nonexistent identifier fails with ResourceNotFoundException",
30 () =>
31 Effect.gen(function* () {
32 // Identifiers must be UUID-shaped — anything else is a ValidationException.
33 const error = yield* Effect.flip(
34 backupsearch.getSearchResultExportJob({
35 ExportJobIdentifier: "00000000-0000-0000-0000-000000000000",
36 }),
37 );
38 expect(error._tag).toBe("ResourceNotFoundException");
39 }),
40 { tags: ["provider:aws", "provider:aws:backupsearch", "live"] },
41);
42
43test.provider(
44 "listSearchJobResults on a nonexistent identifier fails with ResourceNotFoundException",
45 () =>
46 Effect.gen(function* () {
47 // Underlying operation of the ListSearchJobResults runtime binding.
48 const error = yield* Effect.flip(
49 backupsearch.listSearchJobResults({
50 SearchJobIdentifier: "00000000-0000-0000-0000-000000000000",
51 }),
52 );
53 expect(error._tag).toBe("ResourceNotFoundException");
54 }),
55 { tags: ["provider:aws", "provider:aws:backupsearch", "live"] },
56);
57
58test.provider(
59 "listSearchJobBackups on a nonexistent identifier fails with ResourceNotFoundException",
60 () =>
61 Effect.gen(function* () {
62 // Underlying operation of the ListSearchJobBackups runtime binding.
63 const error = yield* Effect.flip(
64 backupsearch.listSearchJobBackups({
65 SearchJobIdentifier: "00000000-0000-0000-0000-000000000000",
66 }),
67 );
68 expect(error._tag).toBe("ResourceNotFoundException");
69 }),
70 { tags: ["provider:aws", "provider:aws:backupsearch", "live"] },
71);
72
73test.provider(
74 "listSearchJobs succeeds on an account-level scan",
75 () =>
76 Effect.gen(function* () {
77 const page = yield* backupsearch.listSearchJobs({ MaxResults: 10 });
78 expect(Array.isArray(page.SearchJobs)).toBe(true);
79 }),
80 { tags: ["provider:aws", "provider:aws:backupsearch", "live"] },
81);
82
83// Search jobs CANNOT be deleted — the API is Start/Stop/Get/List only, and
84// AWS retains terminal job records server-side for ~7 days before they age
85// out of listSearchJobs. "Orphan-free" for this resource therefore means no
86// job is ever left RUNNING: this sweeper stops any still-running job created
87// by this test (crash-safe — covers a run that died before stack.destroy()).
88// It is idempotent: stopping a job that already reached a terminal state is
89// a ConflictException, which means there is nothing to do.
90const stopLeakedSearchJobs = Effect.gen(function* () {
91 const page = yield* backupsearch.listSearchJobs({
92 ByStatus: "RUNNING",
93 MaxResults: 25,
94 });
95 yield* Effect.forEach(
96 (page.SearchJobs ?? []).filter((job) =>
97 job.Name?.startsWith("start-S3-search-job"),
98 ),
99 (job) =>
100 backupsearch
101 .stopSearchJob({ SearchJobIdentifier: job.SearchJobIdentifier! })
102 .pipe(
103 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
104 Effect.catchTag("ConflictException", () => Effect.void),
105 ),
106 );
107}).pipe(Effect.orDie);
108
109// A search job only produces results when the account has recovery points
110// with ACTIVE backup indexes to search — the full lifecycle is gated behind
111// AWS_TEST_BACKUP_SEARCH=1. Search jobs cannot be deleted (7-day server-side
112// retention); destroy stops the job if it is still RUNNING, and terminal
113// job records aging out server-side are the BackupSearch equivalent of a
114// KMS key in PENDING_DELETION — handled, not leaked.
115test.provider.skipIf(!process.env.AWS_TEST_BACKUP_SEARCH)(
116 "start S3 search job, verify, destroy (stop)",
117 (stack) =>
118 Effect.gen(function* () {
119 // Pre-clean: stop any RUNNING job left over from a crashed prior run.
120 yield* stopLeakedSearchJobs;
121 yield* stack.destroy();
122
123 const { job } = yield* stack.deploy(
124 Effect.gen(function* () {
125 const job = yield* SearchJob("Search", {
126 searchScope: { backupResourceTypes: ["S3"] },
127 itemFilters: {
128 s3ItemFilters: [
129 {
130 objectKeys: [{ value: "alchemy-", operator: "BEGINS_WITH" }],
131 },
132 ],
133 },
134 tags: { fixture: "backup-search" },
135 });
136 return { job };
137 }),
138 );
139
140 expect(job.searchJobIdentifier).toBeDefined();
141 expect(job.searchJobArn).toContain("search-job");
142 // In an account without indexed recovery points the job fails fast;
143 // the resource lifecycle (start/observe/stop) is identical either way.
144 expect(["RUNNING", "COMPLETED", "FAILED"]).toContain(job.status);
145
146 // Out-of-band verification via distilled.
147 const observed = yield* backupsearch.getSearchJob({
148 SearchJobIdentifier: job.searchJobIdentifier,
149 });
150 expect(observed.SearchJobArn).toBe(job.searchJobArn);
151 const tags = yield* backupsearch.listTagsForResource({
152 ResourceArn: job.searchJobArn,
153 });
154 expect(tags.Tags?.fixture).toBe("backup-search");
155
156 // Replacement: the search scope/filters are immutable — changing them
157 // must start a NEW search job with a new identifier.
158 const { job: replaced } = yield* stack.deploy(
159 Effect.gen(function* () {
160 const job = yield* SearchJob("Search", {
161 searchScope: { backupResourceTypes: ["S3"] },
162 itemFilters: {
163 s3ItemFilters: [
164 {
165 objectKeys: [{ value: "replaced-", operator: "BEGINS_WITH" }],
166 },
167 ],
168 },
169 tags: { fixture: "backup-search" },
170 });
171 return { job };
172 }),
173 );
174 expect(replaced.searchJobIdentifier).not.toBe(job.searchJobIdentifier);
175
176 // NOTE: a live ExportJob lifecycle additionally requires a search job
177 // in COMPLETED or STOPPED status. In an account without indexed
178 // recovery points every search job ends FAILED ("Export is only
179 // supported for search job with COMPLETED,STOPPED statuses. SearchJob
180 // has status FAILED"), so export coverage needs real, indexed backups.
181
182 // Destroy stops the job (or no-ops if it already completed); the job
183 // record itself is retained by AWS for 7 days and cannot be deleted.
184 yield* stack.destroy();
185 const after = yield* backupsearch.getSearchJob({
186 SearchJobIdentifier: replaced.searchJobIdentifier,
187 });
188 expect(["STOPPING", "STOPPED", "COMPLETED", "FAILED"]).toContain(
189 after.Status,
190 );
191 }).pipe(
192 // Crash-safe teardown: even if any assertion above fails mid-flight,
193 // never leave a search job RUNNING. (Terminal records are un-deletable
194 // and age out server-side within ~7 days.)
195 Effect.ensuring(stopLeakedSearchJobs),
196 ),
197 {
198 tags: ["provider:aws", "provider:aws:backupsearch", "live"],
199 timeout: 120_000,
200 },
201);