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