1import * as SDK from "@distilled.cloud/neon";
2import * as Alchemy from "alchemy";
3import * as Neon from "alchemy/Neon";
4import {
5 DeleteObjectCommand,
6 ListObjectsV2Command,
7 PutObjectCommand,
8 S3Client,
9} from "@aws-sdk/client-s3";
10import * as SQL from "alchemy/SQL/Postgres";
11import * as Test from "alchemy/Test/Bun";
12import { expect } from "bun:test";
13import * as Effect from "effect/Effect";
14import * as Redacted from "effect/Redacted";
15
16const { test } = Test.make({
17 providers: Neon.providers(),
18 state: Alchemy.localState(),
19 profile: process.env.ALCHEMY_PROFILE ?? "testing",
20 stage: "test-neon-compute-preview-isolation",
21});
22const configured = [
23 "PARENT_PROJECT_ID",
24 "PARENT_BRANCH_ID",
25 "PARENT_BUCKET_NAME",
26 "PREVIEW_BRANCH_ID",
27].every((name) => !!process.env[name]);
28
29const makeStorageClient = Effect.fn(function* (
30 config: {
31 endpoint: string;
32 region: string;
33 accessKeyId: string;
34 secretAccessKey: Redacted.Redacted<string>;
35 },
36 bucket: string,
37) {
38 const client = yield* Effect.acquireRelease(
39 Effect.sync(
40 () =>
41 new S3Client({
42 endpoint: config.endpoint,
43 region: config.region,
44 forcePathStyle: true,
45 maxAttempts: 1,
46 requestChecksumCalculation: "WHEN_REQUIRED",
47 credentials: {
48 accessKeyId: config.accessKeyId,
49 secretAccessKey: Redacted.value(config.secretAccessKey),
50 },
51 }),
52 ),
53 (client) => Effect.sync(() => client.destroy()),
54 );
55 return {
56 metadata: (key: string) =>
57 Effect.tryPromise(() =>
58 client.send(
59 new ListObjectsV2Command({ Bucket: bucket, Prefix: key, MaxKeys: 1 }),
60 ),
61 ).pipe(
62 Effect.map((page) => {
63 const object = page.Contents?.find((object) => object.Key === key);
64 return object
65 ? { ETag: object.ETag, ContentLength: object.Size }
66 : undefined;
67 }),
68 ),
69 put: (key: string, body: string, options: { ContentType: string }) =>
70 Effect.tryPromise(() =>
71 client.send(
72 new PutObjectCommand({
73 Bucket: bucket,
74 Key: key,
75 Body: body,
76 ...options,
77 }),
78 ),
79 ),
80 delete: (key: string) =>
81 Effect.tryPromise(() =>
82 client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })),
83 ),
84 };
85});
86
87const database = Effect.fn(function* (scope: {
88 project_id: string;
89 branch_id: string;
90}) {
91 const databases = yield* SDK.listProjectBranchDatabases(scope);
92 const db = databases.databases.find((db) => db.name === "neondb");
93 if (!db)
94 return yield* Effect.fail(
95 new Error("Expected the tutorial neondb database"),
96 );
97 const connection = yield* SDK.getConnectionURI({
98 ...scope,
99 database_name: db.name,
100 role_name: db.owner_name,
101 });
102 return yield* SQL.Postgres({ url: Redacted.make(connection.uri) });
103});
104
105test.provider.skipIf(!configured)(
106 "preview explicitly enables its own upload trigger",
107 (stack) =>
108 Effect.gen(function* () {
109 yield* stack.destroy();
110 const childTriggers = yield* SDK.listProjectBranchTriggers({
111 project_id: process.env.PARENT_PROJECT_ID!,
112 branch_id: process.env.PREVIEW_BRANCH_ID!,
113 });
114 expect(
115 childTriggers.triggers.some(
116 (trigger) =>
117 trigger.name === "PreviewUploads" &&
118 trigger.enabled &&
119 !trigger.inherited,
120 ),
121 ).toBe(true);
122 yield* stack.destroy();
123 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie))),
124 { timeout: 120_000 },
125);
126
127test.provider.skipIf(!configured)(
128 "preview writes leave parent data and backend configuration unchanged",
129 (stack) =>
130 Effect.gen(function* () {
131 yield* stack.destroy();
132 const projectId = process.env.PARENT_PROJECT_ID!;
133 const parentId = process.env.PARENT_BRANCH_ID!;
134 const childId = process.env.PREVIEW_BRANCH_ID!;
135 const bucket = process.env.PARENT_BUCKET_NAME!;
136 expect(childId).not.toBe(parentId);
137 const parent = { project_id: projectId, branch_id: parentId };
138 const child = { project_id: projectId, branch_id: childId };
139 const observedChild = yield* SDK.getProjectBranch(child);
140 expect(observedChild.branch.parent_id).toBe(parentId);
141 const parentFunctions = yield* SDK.listProjectBranchFunctions({
142 ...parent,
143 limit: 1000,
144 });
145 const childFunctions = yield* SDK.listProjectBranchFunctions({
146 ...child,
147 limit: 1000,
148 });
149 expect(parentFunctions.functions.length).toBeGreaterThan(0);
150 expect(parentFunctions.pagination?.next).toBeUndefined();
151 expect(childFunctions.pagination?.next).toBeUndefined();
152 for (const fn of parentFunctions.functions)
153 expect(
154 childFunctions.functions.some(
155 (inherited) => inherited.slug === fn.slug,
156 ),
157 ).toBe(true);
158 const parentTriggers = yield* SDK.listProjectBranchTriggers(parent);
159 const childTriggers = yield* SDK.listProjectBranchTriggers(child);
160 const inherited = childTriggers.triggers.filter(
161 (trigger) => trigger.inherited,
162 );
163 expect(inherited.length).toBeGreaterThan(0);
164 expect(inherited.every((trigger) => !trigger.enabled)).toBe(true);
165 const parentDomains = yield* SDK.listProjectBranchCustomDomains(parent);
166 const childDomains = yield* SDK.listProjectBranchCustomDomains(child);
167 expect(childDomains.custom_domains).toEqual([]);
168 const credentials = yield* stack.deploy(
169 Effect.gen(function* () {
170 return {
171 parent: yield* Neon.Credential("ParentRead", {
172 branch: { projectId, branchId: parentId },
173 scopes: ["storage:read"],
174 }),
175 child: yield* Neon.Credential("ChildWrite", {
176 branch: { projectId, branchId: childId },
177 scopes: ["storage:read", "storage:write"],
178 }),
179 };
180 }),
181 );
182 const parentStorage = yield* SDK.getProjectBranchStorage(parent);
183 const childStorage = yield* SDK.getProjectBranchStorage(child);
184 const parentFiles = yield* makeStorageClient(
185 {
186 endpoint: parentStorage.s3_endpoint,
187 region: parentStorage.region,
188 accessKeyId: credentials.parent.tokenId,
189 secretAccessKey: credentials.parent.s3SecretAccessKey,
190 },
191 bucket,
192 );
193 const childFiles = yield* makeStorageClient(
194 {
195 endpoint: childStorage.s3_endpoint,
196 region: childStorage.region,
197 accessKeyId: credentials.child.tokenId,
198 secretAccessKey: credentials.child.s3SecretAccessKey,
199 },
200 bucket,
201 );
202 const parentSql = yield* database(parent);
203 const childSql = yield* database(child);
204 const baseline = yield* parentSql<{
205 id: string;
206 object_key: string;
207 status: string;
208 }>`SELECT id, object_key, status FROM uploads ORDER BY id`;
209 const sample = baseline.find((row) => row.status === "ready");
210 if (!sample)
211 return yield* Effect.fail(
212 new Error(
213 "Process a parent upload before forking; inherited file coverage must not be vacuous",
214 ),
215 );
216 expect(
217 (yield* childSql`SELECT id FROM uploads WHERE id = ${sample.id}`)
218 .length,
219 ).toBe(1);
220 const original = yield* parentFiles.metadata(sample.object_key);
221 expect(original).toBeDefined();
222 expect((yield* childFiles.metadata(sample.object_key))?.ETag).toBe(
223 original?.ETag,
224 );
225 const key = "tutorial-preview/isolation-probe.txt";
226 const id = "20000000-0000-4000-8000-000000000002";
227 expect(yield* parentFiles.metadata(key)).toBeUndefined();
228 expect(yield* childFiles.metadata(key)).toBeUndefined();
229 expect(
230 (yield* childSql`SELECT id FROM uploads WHERE id = ${id}`).length,
231 ).toBe(0);
232 yield* Effect.gen(function* () {
233 yield* childFiles.put(key, "child only", { ContentType: "text/plain" });
234 yield* childSql`INSERT INTO uploads (id, owner_id, object_key, filename, content_type, expected_bytes)
235 VALUES (${id}, 'preview-isolation-probe', ${key}, 'isolation-probe.txt', 'text/plain', 10)`;
236 expect((yield* childFiles.metadata(key))?.ContentLength).toBe(10);
237 expect(
238 (yield* childSql`SELECT id FROM uploads WHERE id = ${id}`).length,
239 ).toBe(1);
240 expect(yield* parentFiles.metadata(key)).toBeUndefined();
241 expect(
242 yield* parentSql`SELECT id, object_key, status FROM uploads ORDER BY id`,
243 ).toEqual(baseline);
244 expect(yield* SDK.listProjectBranchTriggers(parent)).toEqual(
245 parentTriggers,
246 );
247 expect(yield* SDK.listProjectBranchCustomDomains(parent)).toEqual(
248 parentDomains,
249 );
250 }).pipe(
251 Effect.ensuring(
252 Effect.gen(function* () {
253 yield* childFiles.delete(key);
254 yield* childSql`DELETE FROM uploads WHERE id = ${id} AND owner_id = 'preview-isolation-probe'`;
255 }).pipe(Effect.orDie),
256 ),
257 );
258 yield* stack.destroy();
259 const active = (yield* SDK.listCredentials(child)).credentials.filter(
260 (credential) => !credential.revoked_at,
261 );
262 expect(
263 active.some(
264 (credential) => credential.token_id === credentials.child.tokenId,
265 ),
266 ).toBe(false);
267 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie))),
268 { timeout: 120_000 },
269);