1import * as AWS from "@/AWS";
2import { Vault } from "@/AWS/Glacier";
3import { Topic } from "@/AWS/SNS";
4import * as Test from "@/Test/Alchemy";
5import * as glacier from "@distilled.cloud/aws/glacier";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Result from "effect/Result";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// Ungated typed-error probe: prove the distilled error union carries the
15// not-found tag this provider's read/delete/sync paths depend on. Runs in
16// every CI pass at near-zero cost.
17test.provider(
18 "describeVault on a nonexistent vault fails with ResourceNotFoundException",
19 () =>
20 Effect.gen(function* () {
21 const error = yield* Effect.flip(
22 glacier.describeVault({
23 accountId: "-",
24 vaultName: "alchemy-nonexistent-glacier-vault-probe",
25 }),
26 );
27 expect(error._tag).toBe("ResourceNotFoundException");
28 }),
29 { tags: ["provider:aws", "provider:aws:glacier", "live"] },
30);
31
32// Ungated entitlement probe: AWS rejects the vault-based S3 Glacier API on
33// accounts created after its 2025 deprecation ("This API is no longer
34// supported for new accounts. Please use S3 Glacier storage classes
35// instead.") with the typed NoLongerSupportedException. On an entitled
36// (older) account the create succeeds — clean the probe vault up and move
37// on; the full lifecycle below is what exercises entitled accounts.
38test.provider(
39 "createVault either succeeds (entitled account) or fails with the typed NoLongerSupportedException",
40 () =>
41 Effect.gen(function* () {
42 const vaultName = "alchemy-glacier-entitlement-probe";
43 const created = yield* Effect.result(
44 glacier.createVault({ accountId: "-", vaultName }),
45 );
46 if (Result.isFailure(created)) {
47 expect(created.failure._tag).toBe("NoLongerSupportedException");
48 return;
49 }
50 yield* glacier
51 .deleteVault({ accountId: "-", vaultName })
52 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
53 }),
54 { tags: ["provider:aws", "provider:aws:glacier", "live"] },
55);
56
57class VaultStillExists extends Data.TaggedError("VaultStillExists")<{
58 vaultName: string;
59}> {}
60
61// Typed wait-until-gone: DescribeVault must return the typed
62// ResourceNotFoundException once deletion has propagated.
63const assertVaultDeleted = (vaultName: string) =>
64 Effect.gen(function* () {
65 const exists = yield* glacier
66 .describeVault({ accountId: "-", vaultName })
67 .pipe(
68 Effect.map(() => true),
69 Effect.catchTag("ResourceNotFoundException", () =>
70 Effect.succeed(false),
71 ),
72 );
73 if (exists) {
74 yield* Effect.fail(new VaultStillExists({ vaultName }));
75 }
76 }).pipe(
77 Effect.retry({
78 while: (e) => e._tag === "VaultStillExists",
79 schedule: Schedule.max([
80 Schedule.fixed("2 seconds"),
81 Schedule.recurs(10),
82 ]),
83 }),
84 );
85
86const denyArchiveDeletes = (vaultArn: string) => ({
87 Version: "2012-10-17",
88 Statement: [
89 {
90 Sid: "deny-archive-deletes",
91 Effect: "Deny",
92 Principal: "*",
93 Action: ["glacier:DeleteArchive"],
94 Resource: [vaultArn],
95 },
96 ],
97});
98
99test.provider.skipIf(!process.env.AWS_TEST_GLACIER)(
100 "create vault with tags + notifications, sync access policy, remove config, destroy",
101 (stack) =>
102 Effect.gen(function* () {
103 yield* stack.destroy();
104
105 // Step 1 — vault with tags and a notification configuration.
106 const step1 = yield* stack.deploy(
107 Effect.gen(function* () {
108 const topic = yield* Topic("VaultEvents");
109 const vault = yield* Vault("Backups", {
110 tags: { fixture: "glacier-vault" },
111 notificationConfig: {
112 snsTopic: topic.topicArn,
113 events: ["ArchiveRetrievalCompleted"],
114 },
115 });
116 return { topic, vault };
117 }),
118 );
119
120 expect(step1.vault.vaultName).toBeDefined();
121 expect(step1.vault.vaultArn).toContain(":vaults/");
122 expect(step1.vault.creationDate).toBeDefined();
123
124 // Out-of-band verification via distilled.
125 const described = yield* glacier.describeVault({
126 accountId: "-",
127 vaultName: step1.vault.vaultName,
128 });
129 expect(described.VaultARN).toBe(step1.vault.vaultArn);
130
131 const notifications = yield* glacier.getVaultNotifications({
132 accountId: "-",
133 vaultName: step1.vault.vaultName,
134 });
135 expect(notifications.vaultNotificationConfig?.SNSTopic).toBe(
136 step1.topic.topicArn,
137 );
138 expect(notifications.vaultNotificationConfig?.Events).toEqual([
139 "ArchiveRetrievalCompleted",
140 ]);
141
142 const tags = yield* glacier.listTagsForVault({
143 accountId: "-",
144 vaultName: step1.vault.vaultName,
145 });
146 expect(tags.Tags?.fixture).toBe("glacier-vault");
147 expect(tags.Tags?.["alchemy::id"]).toBeDefined();
148
149 // No access policy yet.
150 const noPolicy = yield* Effect.flip(
151 glacier.getVaultAccessPolicy({
152 accountId: "-",
153 vaultName: step1.vault.vaultName,
154 }),
155 );
156 expect(noPolicy._tag).toBe("ResourceNotFoundException");
157
158 // Step 2 — add an access policy (the vault ARN is deterministic given
159 // the engine-generated name, so we thread the step-1 output through),
160 // change the notification events, and update the tags.
161 const vaultArn = step1.vault.vaultArn;
162 const step2 = yield* stack.deploy(
163 Effect.gen(function* () {
164 const topic = yield* Topic("VaultEvents");
165 const vault = yield* Vault("Backups", {
166 tags: { fixture: "glacier-vault", stage: "two" },
167 accessPolicy: denyArchiveDeletes(vaultArn),
168 notificationConfig: {
169 snsTopic: topic.topicArn,
170 events: [
171 "ArchiveRetrievalCompleted",
172 "InventoryRetrievalCompleted",
173 ],
174 },
175 });
176 return { vault };
177 }),
178 );
179 expect(step2.vault.vaultArn).toBe(vaultArn);
180
181 const policy = yield* glacier.getVaultAccessPolicy({
182 accountId: "-",
183 vaultName: step2.vault.vaultName,
184 });
185 expect(policy.policy?.Policy).toContain("deny-archive-deletes");
186
187 const updatedNotifications = yield* glacier.getVaultNotifications({
188 accountId: "-",
189 vaultName: step2.vault.vaultName,
190 });
191 expect(
192 [
193 ...(updatedNotifications.vaultNotificationConfig?.Events ?? []),
194 ].sort(),
195 ).toEqual(["ArchiveRetrievalCompleted", "InventoryRetrievalCompleted"]);
196
197 const updatedTags = yield* glacier.listTagsForVault({
198 accountId: "-",
199 vaultName: step2.vault.vaultName,
200 });
201 expect(updatedTags.Tags?.stage).toBe("two");
202
203 // Step 3 — remove the access policy and notification configuration;
204 // the reconciler must delete both against observed state.
205 yield* stack.deploy(
206 Effect.gen(function* () {
207 yield* Topic("VaultEvents");
208 const vault = yield* Vault("Backups", {
209 tags: { fixture: "glacier-vault" },
210 });
211 return { vault };
212 }),
213 );
214
215 const removedPolicy = yield* Effect.flip(
216 glacier.getVaultAccessPolicy({
217 accountId: "-",
218 vaultName: step1.vault.vaultName,
219 }),
220 );
221 expect(removedPolicy._tag).toBe("ResourceNotFoundException");
222
223 const removedNotifications = yield* Effect.flip(
224 glacier.getVaultNotifications({
225 accountId: "-",
226 vaultName: step1.vault.vaultName,
227 }),
228 );
229 expect(removedNotifications._tag).toBe("ResourceNotFoundException");
230
231 const prunedTags = yield* glacier.listTagsForVault({
232 accountId: "-",
233 vaultName: step1.vault.vaultName,
234 });
235 expect(prunedTags.Tags?.stage).toBeUndefined();
236
237 yield* stack.destroy();
238 yield* assertVaultDeleted(step1.vault.vaultName);
239 }),
240 {
241 tags: ["provider:aws", "provider:aws:glacier", "provider:aws:sns", "live"],
242 timeout: 240_000,
243 },
244);
245
246test.provider.skipIf(!process.env.AWS_TEST_GLACIER)(
247 "vault lock: initiate in-progress, then abort on removal",
248 (stack) =>
249 Effect.gen(function* () {
250 yield* stack.destroy();
251
252 // Step 1 — plain vault (we need its ARN to author the lock policy).
253 const step1 = yield* stack.deploy(
254 Effect.gen(function* () {
255 return yield* Vault("LockedVault");
256 }),
257 );
258
259 // Step 2 — initiate the vault lock. It must observe as InProgress and
260 // must never be completed by the provider.
261 const vaultArn = step1.vaultArn;
262 yield* stack.deploy(
263 Effect.gen(function* () {
264 return yield* Vault("LockedVault", {
265 lockPolicy: denyArchiveDeletes(vaultArn),
266 });
267 }),
268 );
269
270 const lock = yield* glacier.getVaultLock({
271 accountId: "-",
272 vaultName: step1.vaultName,
273 });
274 expect(lock.State).toBe("InProgress");
275 expect(lock.Policy).toContain("deny-archive-deletes");
276
277 // Reconcile is idempotent while the lock is in progress.
278 yield* stack.deploy(
279 Effect.gen(function* () {
280 return yield* Vault("LockedVault", {
281 lockPolicy: denyArchiveDeletes(vaultArn),
282 });
283 }),
284 );
285
286 // Step 3 — removing the prop aborts the in-progress lock.
287 yield* stack.deploy(
288 Effect.gen(function* () {
289 return yield* Vault("LockedVault");
290 }),
291 );
292 const aborted = yield* Effect.flip(
293 glacier.getVaultLock({
294 accountId: "-",
295 vaultName: step1.vaultName,
296 }),
297 );
298 expect(aborted._tag).toBe("ResourceNotFoundException");
299
300 yield* stack.destroy();
301 yield* assertVaultDeleted(step1.vaultName);
302 }),
303 { tags: ["provider:aws", "provider:aws:glacier", "live"], timeout: 240_000 },
304);
305
306test.provider.skipIf(!process.env.AWS_TEST_GLACIER)(
307 "renaming a vault replaces it",
308 (stack) =>
309 Effect.gen(function* () {
310 yield* stack.destroy();
311
312 const original = yield* stack.deploy(
313 Effect.gen(function* () {
314 return yield* Vault("Renamed");
315 }),
316 );
317
318 const renamed = yield* stack.deploy(
319 Effect.gen(function* () {
320 return yield* Vault("Renamed", {
321 vaultName: "alchemy-test-glacier-vault-renamed",
322 });
323 }),
324 );
325
326 expect(renamed.vaultName).toBe("alchemy-test-glacier-vault-renamed");
327 expect(renamed.vaultArn).not.toBe(original.vaultArn);
328
329 // The new vault exists; the old one was deleted by the replacement.
330 const described = yield* glacier.describeVault({
331 accountId: "-",
332 vaultName: renamed.vaultName,
333 });
334 expect(described.VaultARN).toBe(renamed.vaultArn);
335 yield* assertVaultDeleted(original.vaultName);
336
337 yield* stack.destroy();
338 yield* assertVaultDeleted(renamed.vaultName);
339 }),
340 { tags: ["provider:aws", "provider:aws:glacier", "live"], timeout: 240_000 },
341);