1import {
2 OrganizationApiKey,
3 recoverOrganizationApiKey,
4 type OrganizationApiKeyAttributes,
5 type OrganizationApiKeyProps,
6} from "@alchemy.run/neon/OrganizationApiKey";
7import { Project } from "@alchemy.run/neon/Project";
8import { providers } from "@alchemy.run/neon/Providers";
9import { expect } from "@alchemy.run/test";
10import * as Test from "@alchemy.run/test/Alchemy";
11import * as SDK from "@distilled.cloud/neon";
12import * as Output from "alchemy/Output";
13import * as Effect from "effect/Effect";
14import * as Redacted from "effect/Redacted";
15import * as Result from "effect/Result";
16import * as Schedule from "effect/Schedule";
17
18const { test } = Test.make({ providers: providers() });
19
20const context = {
21 id: "OrganizationKeySafety",
22 fqn: "OrganizationKeySafety",
23 instanceId: "organization-key-safety",
24 oldBindings: [],
25 newBindings: [],
26 bindings: [],
27 session: {
28 note: () => Effect.void,
29 },
30};
31
32type Assert<T extends true> = T;
33type OutputAlwaysRedacted = Assert<
34 OrganizationApiKeyAttributes["key"] extends Redacted.Redacted<string> ? true : false
35>;
36type NoBranchScope = Assert<"branchId" extends keyof OrganizationApiKeyProps ? false : true>;
37type NoSecretInput = Assert<"key" extends keyof OrganizationApiKeyProps ? false : true>;
38const typeAssertions: [OutputAlwaysRedacted, NoBranchScope, NoSecretInput] = [true, true, true];
39
40const scope = { orgId: "org-safety", projectId: "project-safety" };
41const cached: OrganizationApiKeyAttributes = {
42 ...scope,
43 keyId: 123,
44 name: "safety-key",
45 key: Redacted.make("safety-only-not-a-deployment-token"),
46 createdAt: "2026-09-17T00:00:00Z",
47};
48const metadata = {
49 id: cached.keyId,
50 name: cached.name,
51 project_id: cached.projectId,
52 created_at: cached.createdAt,
53};
54
55test(
56 "key types exclude branch scope and secret inputs and require a redacted output",
57 Effect.sync(() => expect(typeAssertions.every(Boolean)).toBe(true)),
58 { tags: ["provider:neon", "provider:neon:organizationapikey", "live"] },
59);
60
61test(
62 "pure recovery preserves the secret only for the exact observed ID and scope",
63 Effect.gen(function* () {
64 const recovered = yield* recoverOrganizationApiKey(scope, metadata, cached);
65 expect(recovered.key).toBe(cached.key);
66 expect(recovered.keyId).toBe(cached.keyId);
67 const renamed = yield* recoverOrganizationApiKey(
68 scope,
69 { ...metadata, name: "observed-label" },
70 cached,
71 );
72 expect(renamed.key).toBe(cached.key);
73 expect(renamed.name).toBe("observed-label");
74 expect(JSON.stringify(recovered)).not.toContain(Redacted.value(cached.key));
75 }),
76 {
77 tags: ["unit", "provider:neon", "provider:neon:organizationapikey", "local"],
78 },
79);
80
81test(
82 "pure recovery rejects missing secrets, revoked IDs, same-name impostors, and scope changes without leaking tokens",
83 Effect.gen(function* () {
84 const missingSecret = { ...cached };
85 yield* Effect.sync(() => Reflect.deleteProperty(missingSecret, "key"));
86 const cases = [
87 recoverOrganizationApiKey(scope, metadata, undefined),
88 recoverOrganizationApiKey(scope, metadata, missingSecret),
89 recoverOrganizationApiKey(scope, undefined, cached),
90 recoverOrganizationApiKey(scope, { ...metadata, id: 124 }, cached),
91 recoverOrganizationApiKey(scope, { ...metadata, id: NaN }, cached),
92 recoverOrganizationApiKey(scope, { ...metadata, project_id: "other-project" }, cached),
93 recoverOrganizationApiKey(scope, { ...metadata, project_id: undefined }, cached),
94 recoverOrganizationApiKey({ ...scope, orgId: "other-org" }, metadata, cached),
95 recoverOrganizationApiKey({ ...scope, projectId: undefined }, metadata, cached),
96 recoverOrganizationApiKey(scope, metadata, {
97 ...cached,
98 key: Redacted.make(""),
99 }),
100 ];
101 for (const recovery of cases) {
102 const result = yield* recovery.pipe(Effect.result);
103 expect(Result.isFailure(result)).toBe(true);
104 if (Result.isFailure(result)) {
105 expect(result.failure._tag).toBe("OrganizationApiKeyRecoveryError");
106 expect(result.failure.message).toContain("Restore the original Alchemy state");
107 expect(result.failure.message).not.toContain(Redacted.value(cached.key));
108 expect(JSON.stringify(result.failure)).not.toContain(Redacted.value(cached.key));
109 }
110 }
111 }),
112 {
113 tags: ["unit", "provider:neon", "provider:neon:organizationapikey", "local"],
114 },
115);
116
117test.provider(
118 "immutable scope/name and unresolved identity changes plan replacements, never in-place rotation",
119 () =>
120 Effect.gen(function* () {
121 const provider = yield* OrganizationApiKey.Provider;
122 for (const news of [
123 { ...scope, orgId: "other-org" },
124 { ...scope, projectId: "other-project" },
125 { ...scope, projectId: undefined },
126 { ...scope, name: "another-name" },
127 { ...scope, orgId: Output.literal(scope.orgId) },
128 { ...scope, projectId: Output.literal(scope.projectId) },
129 ]) {
130 expect(
131 yield* provider.diff!({
132 ...context,
133 olds: scope,
134 news,
135 output: cached,
136 }),
137 ).toMatchObject({ action: "replace" });
138 }
139 expect(
140 yield* provider.diff!({
141 ...context,
142 olds: scope,
143 news: scope,
144 output: cached,
145 }),
146 ).toBeUndefined();
147 expect(
148 yield* provider.diff!({
149 ...context,
150 olds: { ...scope, name: cached.name },
151 news: { ...scope, projectId: "other-project", name: cached.name },
152 output: cached,
153 }),
154 ).toMatchObject({ action: "replace", deleteFirst: true });
155 }),
156 { tags: ["provider:neon", "provider:neon:organizationapikey", "live"] },
157);
158
159test.provider(
160 "reconciliation rejects mismatched cached scope and lost existing output before any cloud call",
161 () =>
162 Effect.gen(function* () {
163 const provider = yield* OrganizationApiKey.Provider;
164 for (const news of [
165 { ...scope, orgId: "other-org" },
166 { ...scope, projectId: "other-project" },
167 { ...scope, projectId: undefined },
168 { ...scope, name: "another-name" },
169 { ...scope, orgId: "" },
170 { ...scope, projectId: "" },
171 ]) {
172 expect(
173 yield* provider.reconcile({ ...context, olds: scope, news, output: cached }).pipe(
174 Effect.as(false),
175 Effect.catchTag("OrganizationApiKeyRecoveryError", () => Effect.succeed(true)),
176 ),
177 ).toBe(true);
178 }
179 expect(
180 yield* provider
181 .reconcile({
182 ...context,
183 olds: scope,
184 news: scope,
185 output: undefined,
186 })
187 .pipe(
188 Effect.as(false),
189 Effect.catchTag("OrganizationApiKeyRecoveryError", () => Effect.succeed(true)),
190 ),
191 ).toBe(true);
192 }),
193 { tags: ["provider:neon", "provider:neon:organizationapikey", "live"] },
194);
195
196test.provider(
197 "pure name diff replaces added or removed explicit names even when the observed label matches",
198 () =>
199 Effect.gen(function* () {
200 const provider = yield* OrganizationApiKey.Provider;
201 const named = { ...scope, name: cached.name };
202 for (const [olds, news] of [
203 [named, scope],
204 [scope, named],
205 ] as const) {
206 expect(yield* provider.diff!({ ...context, olds, news, output: cached })).toMatchObject({
207 action: "replace",
208 });
209 expect(
210 yield* provider.reconcile({ ...context, olds, news, output: cached }).pipe(
211 Effect.as(false),
212 Effect.catchTag("OrganizationApiKeyRecoveryError", () => Effect.succeed(true)),
213 ),
214 ).toBe(true);
215 }
216 expect(
217 yield* provider.diff!({
218 ...context,
219 olds: named,
220 news: scope,
221 output: cached,
222 }),
223 ).toMatchObject({ action: "replace", deleteFirst: false });
224 for (const props of [scope, named]) {
225 expect(
226 yield* provider.diff!({
227 ...context,
228 olds: props,
229 news: props,
230 output: cached,
231 }),
232 ).toBeUndefined();
233 }
234 }),
235 { tags: ["provider:neon", "provider:neon:organizationapikey", "live"] },
236);
237
238const orgId = process.env.NEON_GOVERNANCE_TEST_ORG_ID;
239
240test.provider.skipIf(!orgId)(
241 "project-restricted organization key lifecycle, recovery refusal, replacement and idempotent revoke",
242 (stack) =>
243 Effect.gen(function* () {
244 yield* stack.destroy();
245 const application = (renamed = false) =>
246 Effect.gen(function* () {
247 const project = yield* Project("OrganizationKeyProject", {
248 orgId: orgId!,
249 region: "aws-us-east-2",
250 });
251 const key = yield* OrganizationApiKey("OrganizationKey", {
252 orgId: orgId!,
253 projectId: project.projectId,
254 ...(renamed ? { name: "alchemy-governance-replaced-key" } : {}),
255 });
256 return { project, key };
257 });
258 const first = yield* stack.deploy(application());
259 expect(first.key.orgId).toBe(orgId!);
260 expect(first.key.projectId).toBe(first.project.projectId);
261 expect(Redacted.isRedacted(first.key.key)).toBe(true);
262 expect(first.key.name).toBeTruthy();
263 const request = { org_id: orgId! };
264 const listed = yield* SDK.listOrgApiKeys(request);
265 expect(listed.find((key) => key.id === first.key.keyId)).toMatchObject({
266 name: first.key.name,
267 project_id: first.project.projectId,
268 });
269 const provider = yield* OrganizationApiKey.Provider;
270 const props = {
271 orgId: orgId!,
272 projectId: first.project.projectId,
273 name: first.key.name,
274 };
275 const refreshed = yield* provider.read!({
276 ...context,
277 olds: props,
278 output: first.key,
279 });
280 expect(refreshed?.key).toBe(first.key.key);
281 for (const recovery of [
282 provider.read!({ ...context, olds: props, output: undefined }),
283 provider.reconcile({
284 ...context,
285 olds: undefined,
286 news: props,
287 output: undefined,
288 }),
289 ]) {
290 expect(
291 yield* recovery.pipe(
292 Effect.as(false),
293 Effect.catchTag("OrganizationApiKeyRecoveryError", () => Effect.succeed(true)),
294 ),
295 ).toBe(true);
296 }
297 const again = yield* stack.deploy(application());
298 expect(again.key.keyId).toBe(first.key.keyId);
299 expect(Redacted.value(again.key.key)).toBe(Redacted.value(first.key.key));
300 const replaced = yield* stack.deploy(application(true));
301 expect(replaced.project.projectId).toBe(first.project.projectId);
302 expect(replaced.key.keyId).not.toBe(first.key.keyId);
303 const remaining = yield* SDK.listOrgApiKeys(request).pipe(
304 Effect.repeat({
305 schedule: Schedule.spaced("1 second"),
306 until: (keys) => !keys.some((key) => key.id === first.key.keyId),
307 times: 8,
308 }),
309 );
310 expect(remaining.some((key) => key.id === first.key.keyId)).toBe(false);
311 expect(remaining.find((key) => key.id === replaced.key.keyId)?.project_id).toBe(
312 first.project.projectId,
313 );
314 yield* SDK.revokeOrgApiKey({ ...request, key_id: replaced.key.keyId });
315 expect(
316 yield* provider
317 .reconcile({
318 ...context,
319 olds: { ...props, name: replaced.key.name },
320 news: { ...props, name: replaced.key.name },
321 output: replaced.key,
322 })
323 .pipe(
324 Effect.as(false),
325 Effect.catchTag("OrganizationApiKeyRecoveryError", () => Effect.succeed(true)),
326 ),
327 ).toBe(true);
328 yield* provider.delete({
329 ...context,
330 olds: { ...props, name: replaced.key.name },
331 output: replaced.key,
332 });
333 yield* provider.delete({
334 ...context,
335 olds: { ...props, name: replaced.key.name },
336 output: replaced.key,
337 });
338 yield* stack.destroy();
339 expect(
340 (yield* SDK.listOrgApiKeys(request)).some(
341 (key) => key.id === replaced.key.keyId || key.id === first.key.keyId,
342 ),
343 ).toBe(false);
344 expect(
345 yield* SDK.getProject({ project_id: first.project.projectId }).pipe(
346 Effect.as(false),
347 Effect.catchTag("NotFound", () => Effect.succeed(true)),
348 ),
349 ).toBe(true);
350 yield* stack.destroy();
351 }),
352 {
353 tags: ["provider:neon", "provider:neon:organizationapikey", "provider:neon:project", "live"],
354 timeout: 120_000,
355 },
356);