Skip to content
Source
Sign inSign up
Open History to see the last commit

FunctionArtifact.test.ts

187 lines6.8 KB

1import { buildFunctionArtifact, validateFunctionZip } from "@alchemy.run/neon/FunctionArtifact";
2import { expect, test } from "@alchemy.run/test";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { zipFiles } from "alchemy/Util/zip";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8​
9const scope = { branch: { projectId: "project", branchId: "branch" } };
10​
11test.effect(
12 "prebuilt directory and ZIP share a deterministic root-entry artifact",
13 () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped();
18 yield* fs.writeFileString(
19 path.join(root, "index.mjs"),
20 'export default { fetch: () => new Response("ok") };',
21 );
22 const first = yield* buildFunctionArtifact({ ...scope, artifact: { directory: root } });
23 const second = yield* buildFunctionArtifact({ ...scope, artifact: { directory: root } });
24 expect(second.codeHash).toBe(first.codeHash);
25 const zip = path.join(yield* fs.makeTempDirectoryScoped(), "app.zip");
26 yield* fs.writeFile(zip, first.archive);
27 const prebuilt = yield* buildFunctionArtifact({ ...scope, artifact: { zip } });
28 expect(prebuilt.codeHash).toBe(first.codeHash);
29 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
30 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
31);
32​
33for (const name of [
34 "../secret",
35 "/secret",
36 ".env",
37 "nested/.env.production",
38 "native.node",
39 "nested//duplicate",
40])
41 test.effect(
42 `rejects unsafe artifact path ${name}`,
43 () =>
44 Effect.gen(function* () {
45 const zip = yield* zipFiles([
46 { path: "index.mjs", content: "export default {};" },
47 { path: name, content: "secret" },
48 ]);
49 const rejected = yield* validateFunctionZip(zip).pipe(
50 Effect.as(false),
51 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(true)),
52 );
53 expect(rejected).toBe(true);
54 }),
55 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
56 );
57​
58for (const machine of [183, 62]) {
59 test.effect(
60 `Function ZIP validates native ELF architecture ${machine}`,
61 () =>
62 Effect.gen(function* () {
63 const content = yield* Effect.sync(() => {
64 const bytes = new Uint8Array(64);
65 bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
66 bytes[16] = 3;
67 bytes[18] = machine;
68 return bytes;
69 });
70 const zip = yield* zipFiles([
71 { path: "index.mjs", content: "export default {};" },
72 { path: "node_modules/addon/addon.node", content },
73 ]);
74 const accepted = yield* validateFunctionZip(zip).pipe(
75 Effect.as(true),
76 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(false)),
77 );
78 expect(accepted).toBe(machine === 183);
79 }),
80 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
81 );
83​
84for (const [name, bytes] of [
85 ["darwin.node", [0xcf, 0xfa, 0xed, 0xfe]],
86 ["windows.dll", [0x4d, 0x5a]],
87 ["library.so.1", [0]],
88] as const) {
89 test.effect(
90 `Function ZIP rejects incompatible native file ${name}`,
91 () =>
92 Effect.gen(function* () {
93 const zip = yield* zipFiles([
94 { path: "index.mjs", content: "export default {};" },
95 { path: name, content: yield* Effect.sync(() => new Uint8Array(bytes)) },
96 ]);
97 expect(
98 yield* validateFunctionZip(zip).pipe(
99 Effect.as(false),
100 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(true)),
101 ),
102 ).toBe(true);
103 }),
104 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
105 );
107​
108test.effect(
109 "requires index.mjs at the ZIP root",
110 () =>
111 Effect.gen(function* () {
112 const zip = yield* zipFiles([{ path: "dist/index.mjs", content: "export default {};" }]);
113 expect(
114 yield* validateFunctionZip(zip).pipe(
115 Effect.as(false),
116 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(true)),
117 ),
118 ).toBe(true);
119 }),
120 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
121);
122​
123for (const tamper of ["local-header", "inflated-size"] as const)
124 test.effect(
125 `rejects ZIP ${tamper} size disagreement`,
126 () =>
127 Effect.gen(function* () {
128 const archive = yield* zipFiles([
129 { path: "index.mjs", content: "export default {};".repeat(1000) },
130 ]);
131 yield* Effect.sync(() => {
132 const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
133 const directory = view.getUint32(archive.byteLength - 6, true);
134 view.setUint32(22, 1, true);
135 if (tamper === "inflated-size") view.setUint32(directory + 24, 1, true);
136 });
137 expect(
138 yield* validateFunctionZip(archive).pipe(
139 Effect.as(false),
140 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(true)),
141 ),
142 ).toBe(true);
143 }),
144 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
145 );
146​
147test.effect(
148 "changing a native entrypoint changes uploaded bytes and digest",
149 () =>
150 Effect.gen(function* () {
151 const first = yield* buildFunctionArtifact({
152 ...scope,
153 main: new URL("./fixtures/function-native.ts", import.meta.url).href,
154 isExternal: true,
155 });
156 const second = yield* buildFunctionArtifact({
157 ...scope,
158 main: new URL("./fixtures/function-bare.ts", import.meta.url).href,
159 isExternal: true,
160 });
161 expect(second.codeHash).not.toBe(first.codeHash);
162 const files = yield* validateFunctionZip(second.archive);
163 const entry = yield* Effect.sync(() => new TextDecoder().decode(files["index.mjs"]));
164 expect(entry).toContain("bare-v2");
165 expect(entry).not.toContain("native-v1");
166 }).pipe(Effect.provide(NodeServices.layer)),
167 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
168);
169​
170test.effect(
171 "native bundle targets Node without a Bun or workerd bootstrap",
172 () =>
173 Effect.gen(function* () {
174 const artifact = yield* buildFunctionArtifact({
175 ...scope,
176 main: new URL("./fixtures/function-native.ts", import.meta.url).href,
177 isExternal: true,
178 });
179 const files = yield* validateFunctionZip(artifact.archive);
180 const entry = yield* Effect.sync(() => new TextDecoder().decode(files["index.mjs"]));
181 expect(entry).not.toContain("Bun.serve");
182 expect(entry).not.toContain("cloudflare:workers");
183 expect(entry).not.toContain("workerd");
184 expect(entry).toContain("node:module");
185 }).pipe(Effect.provide(NodeServices.layer)),
186 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
187);