1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import type { ScopedPlanStatusSession } from "@/Report.ts";
4import {
5 normalizePolicyDocument,
6 type PolicyDocument,
7} from "@/AWS/IAM/Policy.ts";
8import { Registry } from "@/AWS/Schemas";
9import * as Provider from "@/Provider";
10import * as Test from "@/Test/Alchemy";
11import * as schemas from "@distilled.cloud/aws/schemas";
12import { describe, expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Result from "effect/Result";
15import * as Schedule from "effect/Schedule";
16
17const { test } = Test.make({ providers: AWS.providers() });
18const stubSession = {
19 note: () => Effect.void,
20} as unknown as ScopedPlanStatusSession;
21
22const assertRegistryGone = (registryName: string) =>
23 schemas.describeRegistry({ RegistryName: registryName }).pipe(
24 Effect.flatMap(() =>
25 Effect.fail(new Error(`registry ${registryName} still exists`)),
26 ),
27 Effect.catchTag("NotFoundException", () => Effect.void),
28 Effect.retry({
29 while: (e) => e instanceof Error,
30 schedule: Schedule.max([
31 Schedule.fixed("2 seconds"),
32 Schedule.recurs(10),
33 ]),
34 }),
35 );
36
37describe(
38 "AWS.Schemas.Registry",
39 { tags: ["provider:aws", "provider:aws:schemas", "live"] },
40 () => {
41 test.provider(
42 "creates, updates, replaces on rename, and deletes a registry",
43 (stack) =>
44 Effect.gen(function* () {
45 yield* stack.destroy();
46
47 // CREATE
48 const created = yield* stack.deploy(
49 Effect.gen(function* () {
50 const registry = yield* Registry("TestRegistry", {
51 description: "alchemy schemas test registry",
52 tags: { purpose: "alchemy-test" },
53 });
54 return {
55 registryName: registry.registryName,
56 registryArn: registry.registryArn,
57 };
58 }),
59 );
60
61 // Verify out-of-band via distilled.
62 const observed = yield* schemas.describeRegistry({
63 RegistryName: created.registryName,
64 });
65 expect(observed.RegistryArn).toEqual(created.registryArn);
66 expect(observed.Description).toEqual("alchemy schemas test registry");
67 expect(observed.Tags?.purpose).toEqual("alchemy-test");
68 expect(observed.Tags?.["alchemy::id"]).toEqual("TestRegistry");
69
70 // list() enumerates the deployed registry (LOCAL scope only).
71 const provider = yield* Provider.findProvider(Registry);
72 const all = yield* provider.list();
73 expect(all.some((r) => r.registryName === created.registryName)).toBe(
74 true,
75 );
76 expect(all.some((r) => r.registryName === "aws.events")).toBe(false);
77
78 // POLICY — attach a PolicyDocument-valued resource policy in place.
79 const { accountId } = yield* AWSEnvironment.current;
80 const policy: PolicyDocument = {
81 Version: "2012-10-17",
82 Statement: [
83 {
84 Sid: "AllowOwnAccountRead",
85 Effect: "Allow",
86 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
87 Action: ["schemas:DescribeRegistry", "schemas:SearchSchemas"],
88 Resource: created.registryArn,
89 },
90 ],
91 };
92 const deployWithPolicy = stack.deploy(
93 Effect.gen(function* () {
94 const registry = yield* Registry("TestRegistry", {
95 description: "alchemy schemas test registry",
96 tags: { purpose: "alchemy-test" },
97 policy,
98 });
99 return { registryName: registry.registryName };
100 }),
101 );
102 const withPolicy = yield* deployWithPolicy;
103 expect(withPolicy.registryName).toEqual(created.registryName);
104
105 const observedPolicy = yield* schemas.getResourcePolicy({
106 RegistryName: created.registryName,
107 });
108 expect(normalizePolicyDocument(observedPolicy.Policy!)).toEqual(
109 normalizePolicyDocument(policy),
110 );
111
112 // NO-OP RE-DEPLOY — the same PolicyDocument deploys clean and skips
113 // putResourcePolicy entirely (the revision id is unchanged).
114 yield* deployWithPolicy;
115 const afterRedeploy = yield* schemas.getResourcePolicy({
116 RegistryName: created.registryName,
117 });
118 expect(afterRedeploy.RevisionId).toEqual(observedPolicy.RevisionId);
119 expect(normalizePolicyDocument(afterRedeploy.Policy!)).toEqual(
120 normalizePolicyDocument(policy),
121 );
122
123 // UPDATE — description changes in place, user tag is removed.
124 const updated = yield* stack.deploy(
125 Effect.gen(function* () {
126 const registry = yield* Registry("TestRegistry", {
127 description: "updated description",
128 });
129 return {
130 registryName: registry.registryName,
131 registryArn: registry.registryArn,
132 };
133 }),
134 );
135 expect(updated.registryName).toEqual(created.registryName);
136
137 const afterUpdate = yield* schemas.describeRegistry({
138 RegistryName: created.registryName,
139 });
140 expect(afterUpdate.Description).toEqual("updated description");
141 expect(afterUpdate.Tags?.purpose).toBeUndefined();
142 expect(afterUpdate.Tags?.["alchemy::id"]).toEqual("TestRegistry");
143
144 // Omitting `policy` removed the resource policy.
145 const policyGone = yield* schemas
146 .getResourcePolicy({ RegistryName: created.registryName })
147 .pipe(
148 Effect.map(() => false),
149 Effect.catchTag("NotFoundException", () => Effect.succeed(true)),
150 );
151 expect(policyGone).toBe(true);
152
153 // REPLACE — an explicit name change replaces the registry.
154 const renamed = yield* stack.deploy(
155 Effect.gen(function* () {
156 const registry = yield* Registry("TestRegistry", {
157 registryName: "alchemy-test-schemas-registry-renamed",
158 });
159 return {
160 registryName: registry.registryName,
161 registryArn: registry.registryArn,
162 };
163 }),
164 );
165 expect(renamed.registryName).toEqual(
166 "alchemy-test-schemas-registry-renamed",
167 );
168 yield* assertRegistryGone(created.registryName);
169 const afterRename = yield* schemas.describeRegistry({
170 RegistryName: renamed.registryName,
171 });
172 expect(afterRename.RegistryArn).toEqual(renamed.registryArn);
173
174 // DELETE
175 yield* stack.destroy();
176 yield* assertRegistryGone(renamed.registryName);
177 }),
178 { tags: ["provider:aws:iam"], timeout: 120_000 },
179 );
180
181 test.provider(
182 "ordinary registry delete protects an untracked schema; force purges it",
183 (stack) =>
184 Effect.gen(function* () {
185 yield* stack.destroy();
186
187 const registry = yield* stack.deploy(
188 Effect.gen(function* () {
189 return yield* Registry("CascadeDelete", {});
190 }),
191 );
192 // Simulate a schema created before state persistence. Nuke can list the
193 // registry but the child provider has no account-wide enumeration.
194 yield* schemas.createSchema({
195 RegistryName: registry.registryName,
196 SchemaName: "UntrackedChild",
197 Type: "OpenApi3",
198 Content: JSON.stringify({
199 openapi: "3.0.0",
200 info: { title: "UntrackedChild", version: "1.0.0" },
201 paths: {},
202 }),
203 });
204
205 const provider = yield* Provider.findProvider(Registry);
206 const deleteInput = {
207 id: "CascadeDelete",
208 fqn: "CascadeDelete",
209 instanceId: "force-delete-test",
210 olds: {},
211 output: registry,
212 session: stubSession,
213 bindings: [],
214 };
215 const protectedDelete = yield* Effect.result(
216 provider.delete(deleteInput),
217 );
218 expect(Result.isFailure(protectedDelete)).toBe(true);
219 yield* schemas.describeSchema({
220 RegistryName: registry.registryName,
221 SchemaName: "UntrackedChild",
222 });
223
224 yield* provider.delete({ ...deleteInput, force: true });
225 yield* assertRegistryGone(registry.registryName);
226 yield* stack.destroy();
227 }),
228 { timeout: 120_000 },
229 );
230 },
231);