1import { adopt } from "@/AdoptPolicy.ts";
2import { Auth } from "@/Neon/Auth.ts";
3import { Branch } from "@/Neon/Branch.ts";
4import { DataApi } from "@/Neon/DataApi.ts";
5import { Project, waitForOperations } from "@/Neon/Project.ts";
6import { providers } from "@/Neon/Providers.ts";
7import * as Test from "@/Test/Alchemy";
8import * as SDK from "@distilled.cloud/neon";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as HttpClient from "effect/http/HttpClient";
12
13const { test } = Test.make({ providers: providers() });
14
15test.provider(
16 "Data API recovery treats incomplete uncreated identities as absent",
17 () =>
18 Effect.gen(function* () {
19 const provider = yield* DataApi.Provider;
20 const missingScope = {
21 branch: {
22 projectId: "uncreated-project",
23 branchId: "uncreated-branch",
24 },
25 };
26 const partialScope = {
27 branch: {
28 projectId: "uncreated-project",
29 branchId: "uncreated-branch",
30 },
31 };
32 yield* Effect.sync(() => {
33 Reflect.deleteProperty(missingScope, "branch");
34 Reflect.deleteProperty(partialScope.branch, "branchId");
35 });
36 for (const olds of [
37 missingScope,
38 partialScope,
39 { branch: { projectId: "", branchId: "uncreated-branch" } },
40 { project: { projectId: "" } },
41 ]) {
42 expect(
43 yield* provider.read!({
44 id: "UncreatedDataApi",
45 fqn: "UncreatedDataApi",
46 instanceId: "uncreated-data-api",
47 olds,
48 output: undefined,
49 }),
50 ).toBeUndefined();
51 }
52 }),
53 { tags: ["provider:neon", "provider:neon:dataapi", "live"] },
54);
55
56test.provider(
57 "Data API infers custom databases and rejects ambiguous selection before mutation",
58 (stack) =>
59 Effect.gen(function* () {
60 yield* stack.destroy();
61 const program = (
62 selection: "infer" | "selected" | "explicit",
63 rows = 10,
64 ) =>
65 Effect.gen(function* () {
66 const project = yield* Project("SelectedDatabaseProject", {
67 region: "aws-us-east-2",
68 databaseName: "selected_app",
69 });
70 const auth = yield* Auth("SelectedDatabaseAuth", {
71 project,
72 database: "selected_app",
73 });
74 const api = yield* DataApi("SelectedDatabaseApi", {
75 branch: {
76 projectId: auth.projectId,
77 branchId: auth.branchId,
78 databaseName:
79 selection === "selected" ? project.databaseName : undefined,
80 },
81 database: selection === "explicit" ? "selected_app" : undefined,
82 authProvider: "neon_auth",
83 settings: { db_max_rows: rows },
84 });
85 return { project, api };
86 });
87 const first = yield* stack.deploy(program("infer"));
88 expect(first.api.database).toBe("selected_app");
89 const request = {
90 project_id: first.api.projectId,
91 branch_id: first.api.branchId,
92 database_name: first.api.database,
93 };
94 const database = yield* SDK.createProjectBranchDatabase({
95 project_id: request.project_id,
96 branch_id: request.branch_id,
97 database: { name: "another_app", owner_name: first.project.roleName },
98 });
99 yield* waitForOperations(database.operations);
100 const selected = yield* stack.deploy(program("selected"));
101 expect(selected.api.url).toBe(first.api.url);
102 const explicit = yield* stack.deploy(program("explicit"));
103 expect(explicit.api.url).toBe(first.api.url);
104 expect(
105 yield* stack.deploy(program("infer", 50)).pipe(
106 Effect.as(false),
107 Effect.catchTag("InvalidDataApiConfiguration", () =>
108 Effect.succeed(true),
109 ),
110 ),
111 ).toBe(true);
112 expect(
113 (yield* SDK.getProjectBranchDataAPI(request)).settings?.db_max_rows,
114 ).toBe(10);
115 yield* stack.destroy();
116 expect(
117 yield* SDK.getProjectBranchDataAPI(request).pipe(
118 Effect.as(false),
119 Effect.catchTag("NotFound", () => Effect.succeed(true)),
120 ),
121 ).toBe(true);
122 yield* stack.destroy();
123 }),
124 {
125 tags: [
126 "provider:neon",
127 "provider:neon:auth",
128 "provider:neon:dataapi",
129 "provider:neon:project",
130 "live",
131 ],
132 timeout: 120_000,
133 },
134);
135
136test.provider(
137 "Data API refuses foreign ownership and explicitly adopts only settings",
138 (stack) =>
139 Effect.gen(function* () {
140 yield* stack.destroy();
141 const base = Effect.gen(function* () {
142 const project = yield* Project("DataAdoptionProject", {
143 region: "aws-us-east-2",
144 });
145 return yield* Auth("DataAdoptionAuth", { project });
146 });
147 const auth = yield* stack.deploy(base);
148 const request = {
149 project_id: auth.projectId,
150 branch_id: auth.branchId,
151 database_name: auth.database,
152 };
153 yield* SDK.createProjectBranchDataAPI({
154 ...request,
155 auth_provider: "neon_auth",
156 add_default_grants: false,
157 settings: { db_max_rows: 10 },
158 });
159 const program = (allow: boolean) =>
160 Effect.gen(function* () {
161 const auth = yield* base;
162 return yield* DataApi("AdoptedDataApi", {
163 branch: { projectId: auth.projectId, branchId: auth.branchId },
164 database: auth.database,
165 settings: { db_max_rows: 20 },
166 }).pipe(adopt(allow));
167 });
168 expect(
169 yield* stack.deploy(program(false)).pipe(
170 Effect.as(false),
171 Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)),
172 ),
173 ).toBe(true);
174 expect(
175 (yield* SDK.getProjectBranchDataAPI(request)).settings?.db_max_rows,
176 ).toBe(10);
177 const adopted = yield* stack.deploy(program(true));
178 expect(adopted.database).toBe(auth.database);
179 expect(
180 (yield* SDK.getProjectBranchDataAPI(request)).settings?.db_max_rows,
181 ).toBe(20);
182 yield* stack.destroy();
183 expect(
184 yield* SDK.getProjectBranchDataAPI(request).pipe(
185 Effect.as(false),
186 Effect.catchTag("NotFound", () => Effect.succeed(true)),
187 ),
188 ).toBe(true);
189 yield* stack.destroy();
190 }),
191 {
192 tags: [
193 "provider:neon",
194 "provider:neon:auth",
195 "provider:neon:dataapi",
196 "provider:neon:project",
197 "live",
198 ],
199 timeout: 120_000,
200 },
201);
202
203test.provider(
204 "Data API replaces an unresolved new branch scope while retaining the old branch",
205 (stack) =>
206 Effect.gen(function* () {
207 yield* stack.destroy();
208 const program = (second: boolean) =>
209 Effect.gen(function* () {
210 const project = yield* Project("DataScopeProject", {
211 region: "aws-us-east-2",
212 });
213 const a = yield* Branch("DataScopeA", { project });
214 const authA = yield* Auth("DataScopeAuthA", { branch: a });
215 const b = second
216 ? yield* Branch("DataScopeB", { project })
217 : undefined;
218 const auth = b ? yield* Auth("DataScopeAuthB", { branch: b }) : authA;
219 const api = yield* DataApi("DataScopeApi", {
220 branch: { projectId: auth.projectId, branchId: auth.branchId },
221 database: auth.database,
222 authProvider: "neon_auth",
223 });
224 return { a, b, api };
225 });
226 const first = yield* stack.deploy(program(false));
227 const updated = yield* stack.deploy(program(true));
228 expect(updated.api.branchId).toBe(updated.b!.branchId);
229 expect(updated.a.branchId).toBe(first.a.branchId);
230 expect(
231 yield* SDK.getProjectBranchDataAPI({
232 project_id: first.api.projectId,
233 branch_id: first.api.branchId,
234 database_name: first.api.database,
235 }).pipe(
236 Effect.as(false),
237 Effect.catchTag("NotFound", () => Effect.succeed(true)),
238 ),
239 ).toBe(true);
240 expect(
241 (yield* SDK.getNeonAuth({
242 project_id: first.api.projectId,
243 branch_id: first.api.branchId,
244 })).base_url,
245 ).toBeDefined();
246 yield* stack.destroy();
247 expect(
248 yield* SDK.getProjectBranchDataAPI({
249 project_id: updated.api.projectId,
250 branch_id: updated.api.branchId,
251 database_name: updated.api.database,
252 }).pipe(
253 Effect.as(false),
254 Effect.catchTag("NotFound", () => Effect.succeed(true)),
255 ),
256 ).toBe(true);
257 yield* stack.destroy();
258 }),
259 {
260 tags: [
261 "provider:neon",
262 "provider:neon:auth",
263 "provider:neon:branch",
264 "provider:neon:dataapi",
265 "provider:neon:project",
266 "live",
267 ],
268 timeout: 120_000,
269 },
270);
271
272test.provider(
273 "Data API lifecycle preserves identity and synchronizes observed settings",
274 (stack) =>
275 Effect.gen(function* () {
276 yield* stack.destroy();
277 const application = (rows: number | undefined) =>
278 Effect.gen(function* () {
279 const project = yield* Project("DataApiProject", {
280 region: "aws-us-east-2",
281 });
282 const branch = yield* Branch("DataApiBranch", { project });
283 const auth = yield* Auth("DataApiAuth", { branch });
284 const api = yield* DataApi("DataApi", {
285 branch: { projectId: auth.projectId, branchId: auth.branchId },
286 authProvider: "neon_auth",
287 settings: { db_max_rows: rows },
288 });
289 return { api, branch };
290 });
291 const first = yield* stack.deploy(application(10));
292 const request = {
293 project_id: first.api.projectId,
294 branch_id: first.api.branchId,
295 database_name: first.api.database,
296 };
297 expect((yield* SDK.getProjectBranchDataAPI(request)).url).toBe(
298 first.api.url,
299 );
300 const unchanged = yield* stack.deploy(application(10));
301 expect(unchanged.api.url).toBe(first.api.url);
302 const updated = yield* stack.deploy(application(20));
303 expect(updated.api.url).toBe(first.api.url);
304 expect(
305 (yield* SDK.getProjectBranchDataAPI(request)).settings?.db_max_rows,
306 ).toBe(20);
307 expect(
308 yield* stack.deploy(application(undefined)).pipe(
309 Effect.as(false),
310 Effect.catchTag("InvalidDataApiConfiguration", () =>
311 Effect.succeed(true),
312 ),
313 ),
314 ).toBe(true);
315 expect(
316 (yield* SDK.getProjectBranchDataAPI(request)).settings?.db_max_rows,
317 ).toBe(20);
318 const http = yield* HttpClient.HttpClient;
319 const denied = yield* http.get(first.api.url, {
320 headers: { authorization: "Bearer invalid-user-token" },
321 });
322 expect(denied.status).toBe(400);
323 expect(yield* denied.json).toMatchObject({
324 message: "Provided authentication token is not a valid JWT encoding",
325 });
326 yield* stack.destroy();
327 expect(
328 yield* SDK.getProjectBranchDataAPI(request).pipe(
329 Effect.as(false),
330 Effect.catchTag("NotFound", () => Effect.succeed(true)),
331 ),
332 ).toBe(true);
333 yield* stack.destroy();
334 }),
335 {
336 tags: [
337 "provider:neon",
338 "provider:neon:auth",
339 "provider:neon:branch",
340 "provider:neon:dataapi",
341 "provider:neon:project",
342 "live",
343 ],
344 timeout: 120_000,
345 },
346);