slicetest 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -7
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +55 -0
- package/dist/auth.js +140 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +72 -5
- package/dist/config.js +74 -6
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +28 -0
- package/dist/db.js +68 -0
- package/dist/doctor.js +17 -5
- package/dist/drivers/driver.d.ts +27 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +2 -1
- package/dist/drivers/sqlite.js +52 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +24 -1
- package/dist/http.js +86 -9
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/init.js +245 -24
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +10 -0
- package/dist/openapi.js +22 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/runtime.d.ts +18 -0
- package/dist/runtime.js +163 -23
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +46 -0
- package/dist/stub.js +109 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +76 -16
- package/dist/yaml.d.ts +67 -1
- package/dist/yaml.js +87 -3
- package/package.json +12 -4
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +320 -0
package/dist/init.js
CHANGED
|
@@ -1,22 +1,84 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
const TODO_COMMAND = "echo 'TODO: the command that starts your app' && exit 1";
|
|
6
|
+
/** SDKs whose API hosts are written into them: a stub with `hosts` answers there. Packages are npm or PyPI names. */
|
|
7
|
+
const KNOWN_APIS = [
|
|
8
|
+
{ packages: ["stripe"], stub: "stripe", hosts: ["api.stripe.com"], env: { STRIPE_SECRET_KEY: "sk_test_slicetest", STRIPE_WEBHOOK_SECRET: "whsec_slicetest" } },
|
|
9
|
+
{ packages: ["@anthropic-ai/sdk", "anthropic"], stub: "anthropic", hosts: ["api.anthropic.com"], env: { ANTHROPIC_API_KEY: "sk-ant-slicetest" } },
|
|
10
|
+
{ packages: ["openai"], stub: "openai", hosts: ["api.openai.com"], env: { OPENAI_API_KEY: "sk-slicetest" } },
|
|
11
|
+
{ packages: ["resend"], stub: "resend", hosts: ["api.resend.com"], env: { RESEND_API_KEY: "re_slicetest" } },
|
|
12
|
+
{ packages: ["@sendgrid/mail", "sendgrid"], stub: "sendgrid", hosts: ["api.sendgrid.com"], env: { SENDGRID_API_KEY: "SG.slicetest" } },
|
|
13
|
+
{ packages: ["@slack/web-api", "slack_sdk"], stub: "slack", hosts: ["slack.com"], env: { SLACK_BOT_TOKEN: "xoxb-slicetest" } },
|
|
14
|
+
{ packages: ["@octokit/rest", "octokit", "PyGithub"], stub: "github", hosts: ["api.github.com"], env: { GITHUB_TOKEN: "ghp_slicetest" } },
|
|
15
|
+
{ packages: ["twilio"], stub: "twilio", hosts: ["api.twilio.com"], env: { TWILIO_ACCOUNT_SID: "AC00000000000000000000000000000000", TWILIO_AUTH_TOKEN: "slicetest" } },
|
|
16
|
+
];
|
|
17
|
+
/** Files that mark a directory as an app slicetest can start. */
|
|
18
|
+
const APP_MARKERS = ["manage.py", "requirements.txt", "pyproject.toml", "Gemfile", "go.mod", "Cargo.toml", "build.gradle", "build.gradle.kts", "pom.xml"];
|
|
19
|
+
/** Build files of a Gradle or Maven multi-project build (two levels down), for dependencies declared in a subproject. */
|
|
20
|
+
async function subprojectBuildFiles(dir, gradle) {
|
|
21
|
+
const names = gradle ? ["build.gradle", "build.gradle.kts"] : ["pom.xml"];
|
|
22
|
+
const texts = [];
|
|
23
|
+
const walk = async (d, depth) => {
|
|
24
|
+
if (depth > 2)
|
|
25
|
+
return;
|
|
26
|
+
for (const entry of await readdir(d, { withFileTypes: true }).catch(() => [])) {
|
|
27
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || ["build", "target", "node_modules", "src", "gradle"].includes(entry.name))
|
|
28
|
+
continue;
|
|
29
|
+
const sub = path.join(d, entry.name);
|
|
30
|
+
for (const n of names)
|
|
31
|
+
if (existsSync(path.join(sub, n)))
|
|
32
|
+
texts.push(await readFile(path.join(sub, n), "utf8"));
|
|
33
|
+
await walk(sub, depth + 1);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
await walk(dir, 1);
|
|
37
|
+
return texts;
|
|
38
|
+
}
|
|
39
|
+
/** Where monorepos usually keep the server, when the root isn't one. */
|
|
40
|
+
const APP_DIRS = ["backend", "server", "api", "app", "service"];
|
|
41
|
+
function runnable(dir) {
|
|
42
|
+
if (APP_MARKERS.some((f) => existsSync(path.join(dir, f))))
|
|
43
|
+
return true;
|
|
44
|
+
try {
|
|
45
|
+
const scripts = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")).scripts ?? {};
|
|
46
|
+
return !!(scripts.start || scripts.dev);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
6
52
|
export async function detect(root) {
|
|
7
53
|
const notes = [];
|
|
8
|
-
|
|
9
|
-
const
|
|
54
|
+
// The app may live in a subdirectory (backend/, server/, …); everything about the app is read there.
|
|
55
|
+
const appDir = runnable(root) ? "" : (APP_DIRS.find((d) => runnable(path.join(root, d))) ?? "");
|
|
56
|
+
if (appDir)
|
|
57
|
+
notes.push(`app: in ${appDir}/ (app.cwd); commands for it run there`);
|
|
58
|
+
const appRoot = path.join(root, appDir);
|
|
59
|
+
const has = (p) => existsSync(path.join(appRoot, p));
|
|
60
|
+
const read = async (p) => (has(p) ? readFile(path.join(appRoot, p), "utf8") : "");
|
|
10
61
|
const pkg = has("package.json") ? JSON.parse(await read("package.json")) : undefined;
|
|
11
62
|
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
12
63
|
const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
|
|
13
64
|
const gemfile = await read("Gemfile");
|
|
14
65
|
// --- app ---
|
|
15
66
|
let command = TODO_COMMAND;
|
|
67
|
+
let jvmBuild;
|
|
68
|
+
let build;
|
|
69
|
+
let readyPath = "/";
|
|
70
|
+
let scope;
|
|
71
|
+
let workers;
|
|
72
|
+
let readyTimeout;
|
|
16
73
|
const env = { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" };
|
|
17
74
|
if (pkg?.scripts?.start) {
|
|
18
75
|
command = "npm start";
|
|
19
76
|
notes.push("app: `npm start` (package.json start script). It must listen on $PORT.");
|
|
77
|
+
if (pkg.scripts.build) {
|
|
78
|
+
// Otherwise `next start` and the like serve whatever was built last, and scenarios pass against stale code.
|
|
79
|
+
build = "npm run build";
|
|
80
|
+
notes.push("app: `npm run build` once per run, before starting (package.json build script).");
|
|
81
|
+
}
|
|
20
82
|
}
|
|
21
83
|
else if (pkg?.scripts?.dev) {
|
|
22
84
|
command = "npm run dev";
|
|
@@ -38,6 +100,41 @@ export async function detect(root) {
|
|
|
38
100
|
command = "bin/rails server -p {{app.port}}";
|
|
39
101
|
notes.push("app: Rails");
|
|
40
102
|
}
|
|
103
|
+
else if (has("build.gradle") || has("build.gradle.kts") || has("pom.xml")) {
|
|
104
|
+
const gradle = !has("pom.xml");
|
|
105
|
+
const buildFile = await read(gradle ? (has("build.gradle.kts") ? "build.gradle.kts" : "build.gradle") : "pom.xml");
|
|
106
|
+
jvmBuild = [buildFile, ...(await subprojectBuildFiles(appRoot, gradle))].join("\n");
|
|
107
|
+
const wrapper = gradle ? (process.platform === "win32" ? "gradlew.bat" : "./gradlew") : process.platform === "win32" ? "mvnw.cmd" : "./mvnw";
|
|
108
|
+
const tool = has(gradle ? "gradlew" : "mvnw") ? wrapper : gradle ? "gradle" : "mvn";
|
|
109
|
+
if (/spring-boot|org\.springframework\.boot/.test(buildFile)) {
|
|
110
|
+
// Built once up front: bootRun / spring-boot:run in several workers would otherwise all compile
|
|
111
|
+
// changed sources at the same moment, and download missing dependencies while the app runs
|
|
112
|
+
// with slicetest's proxy settings (which `offline` refuses). bootJar / package resolve them all.
|
|
113
|
+
build = gradle ? `${tool} bootJar -q` : `${tool} -q package -DskipTests`;
|
|
114
|
+
command = gradle ? `${tool} bootRun -q` : `${tool} -q spring-boot:run`;
|
|
115
|
+
// Spring reads these variables (relaxed binding).
|
|
116
|
+
delete env.PORT;
|
|
117
|
+
delete env.DATABASE_URL;
|
|
118
|
+
Object.assign(env, {
|
|
119
|
+
SERVER_PORT: "{{app.port}}",
|
|
120
|
+
SPRING_DATASOURCE_URL: "{{db.jdbcUrl}}",
|
|
121
|
+
SPRING_DATASOURCE_USERNAME: "{{db.user}}",
|
|
122
|
+
SPRING_DATASOURCE_PASSWORD: "{{db.password}}",
|
|
123
|
+
});
|
|
124
|
+
readyTimeout = 120_000;
|
|
125
|
+
// A JVM takes seconds to start: start it once per worker, and only in a couple of workers.
|
|
126
|
+
scope = "worker";
|
|
127
|
+
workers = 2;
|
|
128
|
+
if (/actuator/.test(buildFile) || (await subprojectBuildFiles(appRoot, gradle)).some((t) => /actuator/.test(t)))
|
|
129
|
+
readyPath = "/actuator/health";
|
|
130
|
+
notes.push(`app: Spring Boot (${gradle ? "Gradle" : "Maven"}). SERVER_PORT and SPRING_DATASOURCE_* override application.yml; {{db.jdbcUrl}} is the JDBC URL`);
|
|
131
|
+
notes.push("app: started once per worker (scope: worker) in 2 workers (workers: 2), since the JVM takes seconds to start");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
command = gradle ? `${tool} run -q` : `${tool} -q exec:java`;
|
|
135
|
+
notes.push(`app: ${gradle ? "Gradle" : "Maven"} project. Check the command; the app must listen on the port in {{app.port}}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
41
138
|
else if (has("go.mod")) {
|
|
42
139
|
command = "go run .";
|
|
43
140
|
notes.push("app: Go (go.mod). It must listen on $PORT.");
|
|
@@ -51,10 +148,11 @@ export async function detect(root) {
|
|
|
51
148
|
}
|
|
52
149
|
// --- migrations ---
|
|
53
150
|
let migrate;
|
|
54
|
-
const migrationsSql = has("migrations") && (await readdir(path.join(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
151
|
+
const migrationsSql = has("migrations") && (await readdir(path.join(appRoot, "migrations"))).some((f) => f.endsWith(".sql"));
|
|
152
|
+
const atlas = await findAtlas(root, appDir);
|
|
153
|
+
if (atlas) {
|
|
154
|
+
migrate = { atlas: { dir: `file://${atlas}` } };
|
|
155
|
+
notes.push(`db: Atlas migrations in ${atlas}/`);
|
|
58
156
|
}
|
|
59
157
|
else if (has("prisma/schema.prisma")) {
|
|
60
158
|
migrate = { command: "npx prisma migrate deploy", inputs: ["prisma/migrations"] };
|
|
@@ -74,8 +172,14 @@ export async function detect(root) {
|
|
|
74
172
|
notes.push("db: Rails migrations");
|
|
75
173
|
}
|
|
76
174
|
else if (deps["drizzle-kit"]) {
|
|
77
|
-
migrate
|
|
78
|
-
|
|
175
|
+
// drizzle-kit migrate/push connect with whatever driver the project has (with only Neon's,
|
|
176
|
+
// over a WebSocket that a local server doesn't answer); the generated SQL needs no driver.
|
|
177
|
+
const configFile = ["drizzle.config.ts", "drizzle.config.js", "drizzle.config.mjs", "drizzle.config.json"].find(has);
|
|
178
|
+
const out = (configFile && /\bout\s*:\s*["'`]([^"'`]+)["'`]/.exec(await read(configFile))?.[1]?.replace(/^\.\//, "")) || "drizzle";
|
|
179
|
+
migrate = { sql: out };
|
|
180
|
+
notes.push(has(out)
|
|
181
|
+
? `db: Drizzle migrations in ${out}/, applied as SQL in name order`
|
|
182
|
+
: `db: Drizzle, migrations expected in ${out}/ (drizzle.config's out), which doesn't exist yet: run \`npx drizzle-kit generate\` and commit them`);
|
|
79
183
|
}
|
|
80
184
|
else if (deps.knex) {
|
|
81
185
|
migrate = { command: "npx knex migrate:latest", inputs: ["migrations"] };
|
|
@@ -92,22 +196,59 @@ export async function detect(root) {
|
|
|
92
196
|
else {
|
|
93
197
|
notes.push("db: no migrations found; the database starts empty. Set db.migrate.");
|
|
94
198
|
}
|
|
199
|
+
// Migrations found in the app's directory: their paths are relative to it, and commands run there.
|
|
200
|
+
if (appDir && migrate && !("atlas" in migrate)) {
|
|
201
|
+
if ("sql" in migrate)
|
|
202
|
+
migrate = { sql: path.posix.join(appDir, migrate.sql) };
|
|
203
|
+
else
|
|
204
|
+
migrate = { command: `cd ${appDir} && ${migrate.command}`, ...(migrate.inputs ? { inputs: migrate.inputs.map((i) => path.posix.join(appDir, i)) } : {}) };
|
|
205
|
+
}
|
|
95
206
|
// --- docker compose: the database image and other dependencies ---
|
|
96
|
-
const { db: composeDb, containers, appEnv, mail: composeMail } = await fromCompose(root, notes);
|
|
207
|
+
const { db: composeDb, containers, appEnv, mail: composeMail } = await fromCompose(root, notes, [...new Set(["", appDir])]);
|
|
97
208
|
Object.assign(env, appEnv);
|
|
98
209
|
let mail = composeMail;
|
|
99
|
-
if (!mail && (deps.nodemailer || /\b(flask-mail|fastapi-mail|django-anymail)\b/.test(python) || (has("manage.py") && /EMAIL_HOST/.test(await read(await djangoSettings(
|
|
210
|
+
if (!mail && (deps.nodemailer || /\b(flask-mail|fastapi-mail|django-anymail)\b/.test(python) || (has("manage.py") && /EMAIL_HOST/.test(await read(await djangoSettings(appRoot)))))) {
|
|
100
211
|
mail = true;
|
|
101
212
|
Object.assign(env, { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" });
|
|
102
213
|
notes.push("mail: the app sends mail; slicetest catches it over SMTP (SMTP_HOST / SMTP_PORT, rename them to what your app reads)");
|
|
103
214
|
}
|
|
104
|
-
const sqlite = await detectSqlite(
|
|
215
|
+
const sqlite = await detectSqlite(appRoot, read, deps, python, gemfile);
|
|
105
216
|
if (sqlite && !composeDb.engine) {
|
|
106
217
|
composeDb.engine = "sqlite";
|
|
107
218
|
env.DATABASE_URL = sqlite.url;
|
|
108
219
|
notes.push(`db: SQLite (${sqlite.why}), no container needed. The app gets DATABASE_URL=${sqlite.url}; {{db.path}} is the plain file path`);
|
|
109
220
|
}
|
|
110
|
-
|
|
221
|
+
let authClerk = false;
|
|
222
|
+
const auth = await authFromEnvExample(appRoot, read, env, notes);
|
|
223
|
+
const jwtDeps = ["jose", "jsonwebtoken", "passport-jwt", "express-oauth2-jwt-bearer", "express-jwt", "jwks-rsa", "@fastify/jwt", "next-auth"].filter((d) => deps[d]);
|
|
224
|
+
const jwtPython = /\b(pyjwt|python-jose|authlib|fastapi-azure-auth|djangorestframework-simplejwt)\b/i.exec(python)?.[1];
|
|
225
|
+
if (!auth && (jwtDeps.length || jwtPython)) {
|
|
226
|
+
notes.push(`auth: ${jwtDeps[0] ?? jwtPython} is a dependency, so the app may verify JWTs. To test with real tokens, add \`auth: true\` and pass {{auth.issuer}} / {{auth.jwks}} / {{auth.audience}} to the variables the app reads`);
|
|
227
|
+
}
|
|
228
|
+
const stubs = await stubsFromEnvExample(appRoot, read, env, notes);
|
|
229
|
+
const envExample = [".env.example", ".env.sample", ".env.template", ".env.dist"].map((f) => (has(f) ? f : "")).find(Boolean);
|
|
230
|
+
const exampleText = envExample ? await read(envExample) : "";
|
|
231
|
+
for (const api of KNOWN_APIS) {
|
|
232
|
+
if (!api.packages.some((p) => deps[p] || new RegExp(`\\b${p.replace(/[^\w-]/g, "")}\\b`).test(python)))
|
|
233
|
+
continue;
|
|
234
|
+
if (stubs.some((s) => (typeof s === "string" ? s : s.name) === api.stub))
|
|
235
|
+
continue;
|
|
236
|
+
stubs.push({ name: api.stub, hosts: api.hosts });
|
|
237
|
+
// Placeholder credentials for the variables the app is documented to read; the stub doesn't check them.
|
|
238
|
+
for (const [k, v] of Object.entries(api.env))
|
|
239
|
+
if (!(k in env) && new RegExp(`^\\s*${k}\\s*=`, "m").test(exampleText))
|
|
240
|
+
env[k] = v;
|
|
241
|
+
notes.push(`stubs.${api.stub}: ${api.packages.find((p) => deps[p]) ?? api.packages[0]} calls ${api.hosts.join(", ")}; the stub answers there (register its routes in scenarios)`);
|
|
242
|
+
}
|
|
243
|
+
if (deps["@clerk/nextjs"] || deps["@clerk/express"] || deps["@clerk/backend"]) {
|
|
244
|
+
// A publishable key is the Frontend API host, base64-encoded with a trailing "$".
|
|
245
|
+
env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ??= `pk_test_${Buffer.from("clerk.slicetest.test$").toString("base64")}`;
|
|
246
|
+
env.CLERK_SECRET_KEY ??= "sk_test_slicetest";
|
|
247
|
+
authClerk = true;
|
|
248
|
+
stubs.push({ name: "clerk", hosts: ["api.clerk.com", "clerk.slicetest.test"] });
|
|
249
|
+
notes.push("auth: Clerk. Sessions are tokens from slicetest's issuer (auth: true); the clerk stub serves its keys at /v1/jwks and users at /v1/users/:id. See the README's Clerk section");
|
|
250
|
+
}
|
|
251
|
+
notes.push("network: URLs written in the code (https://api.example.com) can be stubbed with `hosts`. Add `offline: true` and the first run names every host the app calls");
|
|
111
252
|
const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
|
|
112
253
|
if (!composeDb.engine && mysqlDeps) {
|
|
113
254
|
composeDb.engine = "mysql";
|
|
@@ -117,11 +258,31 @@ export async function detect(root) {
|
|
|
117
258
|
const openapi = ["openapi.yaml", "openapi.yml", "openapi.json", "docs/openapi.yaml", "docs/openapi.yml", "docs/openapi.json"].find(has);
|
|
118
259
|
if (openapi)
|
|
119
260
|
notes.push(`openapi: ${openapi}. Every response will be checked against it.`);
|
|
261
|
+
const neon = !!(deps["@neondatabase/serverless"] || deps["@vercel/postgres"]);
|
|
262
|
+
if (neon)
|
|
263
|
+
notes.push("db: Neon's serverless driver (db.neon): DATABASE_URL is a Neon-style URL whose HTTP queries slicetest answers from the test database");
|
|
264
|
+
// An app with no migrations, no database in compose and no database library gets no database at all.
|
|
265
|
+
const dbLibrary = Object.keys(deps).some((d) => /^(pg|postgres|mysql2?|@prisma\/client|prisma|drizzle-orm|knex|typeorm|sequelize|kysely|better-sqlite3|sqlite3|@neondatabase\/serverless|@vercel\/postgres|@libsql\/client|mongoose|mongodb)$/.test(d)) ||
|
|
266
|
+
/data-jpa|data-jdbc|spring-jdbc|r2dbc|postgresql|mysql|flyway|liquibase|hibernate/.test(jvmBuild ?? "") ||
|
|
267
|
+
/\b(psycopg|sqlalchemy|asyncpg|pymysql|mysqlclient|django|peewee|tortoise|sqlmodel)\b/.test(python) ||
|
|
268
|
+
/\b(rails|activerecord|sequel|pg|mysql2|sqlite3)\b/.test(gemfile);
|
|
269
|
+
const knownStack = !!pkg || jvmBuild !== undefined || python.trim().length > 0 || gemfile.length > 0;
|
|
270
|
+
const noDb = knownStack && !dbLibrary && !migrate && !composeDb.engine && !composeDb.image && !has("manage.py");
|
|
271
|
+
if (noDb) {
|
|
272
|
+
for (const k of ["DATABASE_URL", "SPRING_DATASOURCE_URL", "SPRING_DATASOURCE_USERNAME", "SPRING_DATASOURCE_PASSWORD"])
|
|
273
|
+
delete env[k];
|
|
274
|
+
const i = notes.indexOf("db: no migrations found; the database starts empty. Set db.migrate.");
|
|
275
|
+
if (i >= 0)
|
|
276
|
+
notes.splice(i, 1);
|
|
277
|
+
notes.push("db: none (no migrations, no database in compose, no database library), so `db: false`: no container is started");
|
|
278
|
+
}
|
|
120
279
|
const config = {
|
|
121
|
-
app: { command, env, ready: { path:
|
|
122
|
-
...(
|
|
280
|
+
app: { ...(appDir ? { cwd: appDir } : {}), command, ...(build ? { build } : {}), env, ready: { path: readyPath }, ...(readyTimeout ? { readyTimeout } : {}), ...(scope ? { scope } : {}) },
|
|
281
|
+
...(workers ? { workers } : {}),
|
|
282
|
+
...(noDb ? { db: false } : migrate || neon || Object.keys(composeDb).length ? { db: { ...composeDb, ...(neon ? { neon: true } : {}), ...(migrate ? { migrate } : {}) } } : {}),
|
|
123
283
|
...(Object.keys(containers).length ? { containers } : {}),
|
|
124
284
|
...(mail ? { mail: true } : {}),
|
|
285
|
+
...(auth || authClerk ? { auth: true } : {}),
|
|
125
286
|
stubs,
|
|
126
287
|
...(openapi ? { openapi } : {}),
|
|
127
288
|
};
|
|
@@ -150,6 +311,42 @@ async function detectSqlite(root, read, deps, python, gemfile) {
|
|
|
150
311
|
return { why: "a SQLite driver is a dependency", url: "{{db.url}}" };
|
|
151
312
|
return undefined;
|
|
152
313
|
}
|
|
314
|
+
/** Variables that tell the app which token issuer to trust, and what slicetest passes instead. */
|
|
315
|
+
const AUTH_VARS = [
|
|
316
|
+
[/JWKS(_URL|_URI|_ENDPOINT)?$/, "{{auth.jwks}}"],
|
|
317
|
+
[/(ISSUER|ISSUER_URL|ISSUER_BASE_URL|AUTHORITY)$/, "{{auth.issuer}}"],
|
|
318
|
+
[/^(AUTH0|OKTA|KEYCLOAK|COGNITO|OIDC|OAUTH2?|AUTH|JWT)_?(DOMAIN|URL|BASE_URL)$/, "{{auth.issuer}}"],
|
|
319
|
+
[/(AUDIENCE|_AUD)$/, "{{auth.audience}}"],
|
|
320
|
+
];
|
|
321
|
+
/**
|
|
322
|
+
* `.env.example` variables naming a token issuer (`OIDC_ISSUER`, `AUTH0_DOMAIN`,
|
|
323
|
+
* `JWKS_URL`, `JWT_AUDIENCE`) turn on `auth`, with the variables pointed at
|
|
324
|
+
* slicetest's issuer. Returns whether any was found.
|
|
325
|
+
*/
|
|
326
|
+
async function authFromEnvExample(root, read, env, notes) {
|
|
327
|
+
const file = [".env.example", ".env.sample", ".env.template", ".env.dist"].find((f) => existsSync(path.join(root, f)));
|
|
328
|
+
if (!file)
|
|
329
|
+
return false;
|
|
330
|
+
const found = [];
|
|
331
|
+
for (const line of (await read(file)).split(/\r?\n/)) {
|
|
332
|
+
const name = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/.exec(line)?.[1];
|
|
333
|
+
if (!name || name in env)
|
|
334
|
+
continue;
|
|
335
|
+
const match = AUTH_VARS.find(([re]) => re.test(name));
|
|
336
|
+
// AUDIENCE alone doesn't say the app verifies tokens; it comes along with an issuer or JWKS.
|
|
337
|
+
if (!match)
|
|
338
|
+
continue;
|
|
339
|
+
env[name] = match[1];
|
|
340
|
+
found.push(name);
|
|
341
|
+
}
|
|
342
|
+
if (!found.some((n) => env[n] !== "{{auth.audience}}")) {
|
|
343
|
+
for (const n of found)
|
|
344
|
+
delete env[n];
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
notes.push(`auth: ${found.join(", ")} in ${file} point at slicetest's OpenID issuer; scenarios mint tokens with auth.token({ sub, ... }) or \`auth:\` on a YAML request. An issuer the app expects as a bare domain (Auth0) may need the variable reshaped`);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
153
350
|
/** Hosts that are never third-party APIs to stub. */
|
|
154
351
|
const LOCAL_HOST = /^(localhost|127\.|0\.0\.0\.0|\[::1\]|host\.docker\.internal|[\w-]+$)/;
|
|
155
352
|
/**
|
|
@@ -164,7 +361,7 @@ async function stubsFromEnvExample(root, read, env, notes) {
|
|
|
164
361
|
const stubs = [];
|
|
165
362
|
for (const line of (await read(file)).split(/\r?\n/)) {
|
|
166
363
|
const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*["']?(https?:\/\/[^\s"'#]+)/.exec(line);
|
|
167
|
-
if (!m || !/(URL|URI|ENDPOINT|HOST|BASE)$/.test(m[1]) || /DATABASE|REDIS|MONGO|AMQP|SMTP|MAIL|CALLBACK|REDIRECT|FRONTEND|PUBLIC|APP_URL|SITE_URL|WEBHOOK_URL$/.test(m[1]))
|
|
364
|
+
if (!m || m[1] in env || !/(URL|URI|ENDPOINT|HOST|BASE)$/.test(m[1]) || /DATABASE|REDIS|MONGO|AMQP|SMTP|MAIL|CALLBACK|REDIRECT|FRONTEND|PUBLIC|APP_URL|SITE_URL|WEBHOOK_URL$/.test(m[1]))
|
|
168
365
|
continue;
|
|
169
366
|
const url = new URL(m[2]);
|
|
170
367
|
if (LOCAL_HOST.test(url.hostname))
|
|
@@ -179,14 +376,17 @@ async function stubsFromEnvExample(root, read, env, notes) {
|
|
|
179
376
|
}
|
|
180
377
|
return stubs;
|
|
181
378
|
}
|
|
182
|
-
|
|
379
|
+
/**
|
|
380
|
+
* A first scenario against the path the app is checked for readiness on. At `/` an API often
|
|
381
|
+
* answers 404, so there it only checks that the app answers (and doesn't crash).
|
|
382
|
+
*/
|
|
383
|
+
const scenario = (readyPath) => `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
|
|
183
384
|
# A first scenario. Run it with: npx slicetest
|
|
184
385
|
scenarios:
|
|
185
386
|
- name: the app answers
|
|
186
387
|
steps:
|
|
187
|
-
- request: GET
|
|
188
|
-
expect: { status: 200 }
|
|
189
|
-
`;
|
|
388
|
+
- request: GET ${readyPath}
|
|
389
|
+
${readyPath === "/" ? " # expect: { status: 200 } (what should / answer? An API often has no page there)\n" : " expect: { status: 200 }\n"}`;
|
|
190
390
|
export async function init(root, { force = false } = {}) {
|
|
191
391
|
const configFile = path.join(root, "slicetest.config.yaml");
|
|
192
392
|
const scenarioFile = path.join(root, "scenarios", "smoke.scenario.yaml");
|
|
@@ -204,10 +404,31 @@ export async function init(root, { force = false } = {}) {
|
|
|
204
404
|
].join("\n");
|
|
205
405
|
await writeFile(configFile, `${header}${stringify(config)}`);
|
|
206
406
|
await mkdir(path.dirname(scenarioFile), { recursive: true });
|
|
207
|
-
await writeFile(scenarioFile,
|
|
407
|
+
await writeFile(scenarioFile, scenario(config.app.ready?.path ?? "/"));
|
|
208
408
|
return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
|
|
209
409
|
}
|
|
210
|
-
const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
|
|
410
|
+
const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml", "podman-compose.yml", "podman-compose.yaml"];
|
|
411
|
+
/**
|
|
412
|
+
* The Atlas migrations directory, relative to the project root: from an atlas.hcl's
|
|
413
|
+
* `dir = "file://..."` (in the root, the app's directory or atlas/, db/), else a
|
|
414
|
+
* migrations/ folder with an atlas.sum.
|
|
415
|
+
*/
|
|
416
|
+
async function findAtlas(root, appDir) {
|
|
417
|
+
for (const dir of [...new Set(["", appDir, "atlas", "db", "database"])]) {
|
|
418
|
+
const hcl = path.join(root, dir, "atlas.hcl");
|
|
419
|
+
if (!existsSync(hcl))
|
|
420
|
+
continue;
|
|
421
|
+
const found = /\bdir\s*=\s*"file:\/\/([^"]+)"/.exec(await readFile(hcl, "utf8"))?.[1];
|
|
422
|
+
const rel = path.posix.join(dir.replace(/\\/g, "/"), (found ?? "migrations").replace(/^\.\//, ""));
|
|
423
|
+
if (existsSync(path.join(root, rel)))
|
|
424
|
+
return rel;
|
|
425
|
+
}
|
|
426
|
+
for (const dir of [...new Set(["migrations", path.posix.join(appDir, "migrations")])]) {
|
|
427
|
+
if (existsSync(path.join(root, dir, "atlas.sum")))
|
|
428
|
+
return dir;
|
|
429
|
+
}
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
211
432
|
/** Development mail servers; `mail: true` does their job in-process. */
|
|
212
433
|
const MAIL_CATCHERS = /(^|\/)(mailpit|mailhog|maildev|smtp4dev|greenmail[\w-]*|mailcatcher|inbucket)(:|$)/;
|
|
213
434
|
/** Known images: the port they listen on, how to empty them, and the variable apps usually read. */
|
|
@@ -229,12 +450,12 @@ const KNOWN = [
|
|
|
229
450
|
* Reads docker compose: a postgres / mysql service sets the database engine
|
|
230
451
|
* and image; Redis, Mongo, MinIO and other images become `containers`.
|
|
231
452
|
*/
|
|
232
|
-
async function fromCompose(root, notes) {
|
|
453
|
+
async function fromCompose(root, notes, dirs = [""]) {
|
|
233
454
|
const db = {};
|
|
234
455
|
const containers = {};
|
|
235
456
|
const appEnv = {};
|
|
236
457
|
let mail = false;
|
|
237
|
-
const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
|
|
458
|
+
const file = dirs.flatMap((d) => COMPOSE_FILES.map((f) => path.join(d, f))).find((f) => existsSync(path.join(root, f)));
|
|
238
459
|
if (!file)
|
|
239
460
|
return { db, containers, appEnv, mail };
|
|
240
461
|
let doc;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
/** Where an intercepted host's calls go: its stub's server (TLS connections) and port (plain HTTP). */
|
|
3
|
+
export interface Route {
|
|
4
|
+
attach: (socket: net.Socket) => void;
|
|
5
|
+
port: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The entry of `map` for `host`: an exact name, else the closest `*.domain` pattern
|
|
9
|
+
* (which covers every subdomain, at any depth, but not the domain itself).
|
|
10
|
+
*/
|
|
11
|
+
export declare function lookupHost<T>(map: ReadonlyMap<string, T>, host: string): T | undefined;
|
|
12
|
+
export declare class Interceptor {
|
|
13
|
+
#private;
|
|
14
|
+
readonly routes: ReadonlyMap<string, Route>;
|
|
15
|
+
readonly files: {
|
|
16
|
+
dir: string;
|
|
17
|
+
ca: string;
|
|
18
|
+
bundle: string;
|
|
19
|
+
trustStore: string;
|
|
20
|
+
};
|
|
21
|
+
/** Hosts the app reached that no stub intercepts, in the order first seen. */
|
|
22
|
+
readonly passedThrough: Set<string>;
|
|
23
|
+
/** With `offline`, hosts the app tried to reach and was refused. */
|
|
24
|
+
readonly blocked: Set<string>;
|
|
25
|
+
/** Refuse hosts no stub intercepts instead of passing calls through. */
|
|
26
|
+
offline: boolean;
|
|
27
|
+
private constructor();
|
|
28
|
+
get url(): string;
|
|
29
|
+
/** `routes` maps lower-case host names to the stub that answers for them. */
|
|
30
|
+
static start(routes: ReadonlyMap<string, Route>): Promise<Interceptor>;
|
|
31
|
+
/**
|
|
32
|
+
* The environment that sends a process's outbound HTTP(S) through the proxy and
|
|
33
|
+
* makes it trust the CA. Calls to localhost (the stubs' own URLs, the database) bypass it.
|
|
34
|
+
*/
|
|
35
|
+
env(base?: NodeJS.ProcessEnv): Record<string, string>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catching calls to hosts the app has hard-coded (`https://api.github.com`), so
|
|
3
|
+
* stubs can answer them without the app reading a base URL from its environment.
|
|
4
|
+
*
|
|
5
|
+
* The app is started with the usual proxy variables pointing at this proxy and a
|
|
6
|
+
* throwaway CA in the trust settings each runtime reads (Node, Python, Ruby, Go,
|
|
7
|
+
* curl, the JVM). For an intercepted host the proxy terminates TLS with a
|
|
8
|
+
* certificate from that CA and hands the connection to the stub's HTTP server;
|
|
9
|
+
* every other host is tunnelled through untouched.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import tls from "node:tls";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { Authority, pemBundle, pemToDer, pkcs12TrustStore } from "./x509.js";
|
|
19
|
+
let authority;
|
|
20
|
+
/** preload/node-proxy.cjs, from src/ and dist/ alike. */
|
|
21
|
+
const PRELOAD = fileURLToPath(new URL("../preload/node-proxy.cjs", import.meta.url));
|
|
22
|
+
/**
|
|
23
|
+
* The entry of `map` for `host`: an exact name, else the closest `*.domain` pattern
|
|
24
|
+
* (which covers every subdomain, at any depth, but not the domain itself).
|
|
25
|
+
*/
|
|
26
|
+
export function lookupHost(map, host) {
|
|
27
|
+
host = host.toLowerCase();
|
|
28
|
+
const exact = map.get(host);
|
|
29
|
+
if (exact !== undefined)
|
|
30
|
+
return exact;
|
|
31
|
+
for (let i = host.indexOf("."); i >= 0; i = host.indexOf(".", i + 1)) {
|
|
32
|
+
const wildcard = map.get(`*${host.slice(i)}`);
|
|
33
|
+
if (wildcard !== undefined)
|
|
34
|
+
return wildcard;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export class Interceptor {
|
|
39
|
+
routes;
|
|
40
|
+
files;
|
|
41
|
+
/** Hosts the app reached that no stub intercepts, in the order first seen. */
|
|
42
|
+
passedThrough = new Set();
|
|
43
|
+
/** With `offline`, hosts the app tried to reach and was refused. */
|
|
44
|
+
blocked = new Set();
|
|
45
|
+
/** Refuse hosts no stub intercepts instead of passing calls through. */
|
|
46
|
+
offline = false;
|
|
47
|
+
#server;
|
|
48
|
+
#sockets = new Set();
|
|
49
|
+
#contexts = new Map();
|
|
50
|
+
constructor(server, routes, files) {
|
|
51
|
+
this.routes = routes;
|
|
52
|
+
this.files = files;
|
|
53
|
+
this.#server = server;
|
|
54
|
+
}
|
|
55
|
+
get url() {
|
|
56
|
+
return `http://127.0.0.1:${this.#server.address().port}`;
|
|
57
|
+
}
|
|
58
|
+
/** `routes` maps lower-case host names to the stub that answers for them. */
|
|
59
|
+
static async start(routes) {
|
|
60
|
+
authority ??= new Authority();
|
|
61
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "slicetest-intercept-"));
|
|
62
|
+
const files = { dir, ca: path.join(dir, "ca.pem"), bundle: path.join(dir, "ca-bundle.pem"), trustStore: path.join(dir, "truststore.p12") };
|
|
63
|
+
await writeFile(files.ca, authority.pem);
|
|
64
|
+
await writeFile(files.bundle, pemBundle(tls.rootCertificates, authority.pem));
|
|
65
|
+
const roots = tls.rootCertificates.flatMap((pem, i) => {
|
|
66
|
+
const der = pemToDer(pem);
|
|
67
|
+
return der ? [{ alias: `root-${i}`, der }] : [];
|
|
68
|
+
});
|
|
69
|
+
await writeFile(files.trustStore, pkcs12TrustStore([{ alias: "slicetest-ca", der: authority.der }, ...roots]));
|
|
70
|
+
const server = http.createServer();
|
|
71
|
+
const interceptor = new Interceptor(server, routes, files);
|
|
72
|
+
server.on("request", (req, res) => interceptor.#plain(req, res));
|
|
73
|
+
server.on("connect", (req, socket, head) => interceptor.#tunnel(req, socket, head));
|
|
74
|
+
server.on("connection", (s) => {
|
|
75
|
+
interceptor.#sockets.add(s);
|
|
76
|
+
s.on("close", () => interceptor.#sockets.delete(s));
|
|
77
|
+
});
|
|
78
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
79
|
+
return interceptor;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The environment that sends a process's outbound HTTP(S) through the proxy and
|
|
83
|
+
* makes it trust the CA. Calls to localhost (the stubs' own URLs, the database) bypass it.
|
|
84
|
+
*/
|
|
85
|
+
env(base = process.env) {
|
|
86
|
+
const { port } = new URL(this.url);
|
|
87
|
+
const local = "localhost,127.0.0.1,::1";
|
|
88
|
+
const quote = (s) => (/\s/.test(s) ? `"${s}"` : s);
|
|
89
|
+
const java = [
|
|
90
|
+
`-Dhttp.proxyHost=127.0.0.1`,
|
|
91
|
+
`-Dhttp.proxyPort=${port}`,
|
|
92
|
+
`-Dhttps.proxyHost=127.0.0.1`,
|
|
93
|
+
`-Dhttps.proxyPort=${port}`,
|
|
94
|
+
`-Dhttp.nonProxyHosts=localhost|127.0.0.1|[::1]`,
|
|
95
|
+
quote(`-Djavax.net.ssl.trustStore=${this.files.trustStore}`),
|
|
96
|
+
`-Djavax.net.ssl.trustStoreType=PKCS12`,
|
|
97
|
+
];
|
|
98
|
+
return {
|
|
99
|
+
HTTP_PROXY: this.url,
|
|
100
|
+
HTTPS_PROXY: this.url,
|
|
101
|
+
http_proxy: this.url,
|
|
102
|
+
https_proxy: this.url,
|
|
103
|
+
NO_PROXY: local,
|
|
104
|
+
no_proxy: local,
|
|
105
|
+
// Node 22.21+ / 24.5+ honour the proxy variables for fetch and the default agents with this set;
|
|
106
|
+
// the preload extends that to agents libraries make themselves.
|
|
107
|
+
NODE_USE_ENV_PROXY: "1",
|
|
108
|
+
NODE_OPTIONS: [base.NODE_OPTIONS, `--require ${quote(PRELOAD)}`].filter(Boolean).join(" "),
|
|
109
|
+
NODE_EXTRA_CA_CERTS: this.files.ca,
|
|
110
|
+
// OpenSSL-based runtimes (Python, Ruby, PHP), Go, requests and curl replace their
|
|
111
|
+
// default roots with these files, so the bundle also holds the public roots.
|
|
112
|
+
SSL_CERT_FILE: this.files.bundle,
|
|
113
|
+
REQUESTS_CA_BUNDLE: this.files.bundle,
|
|
114
|
+
CURL_CA_BUNDLE: this.files.bundle,
|
|
115
|
+
JAVA_TOOL_OPTIONS: [base.JAVA_TOOL_OPTIONS, ...java].filter(Boolean).join(" "),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
#context(host) {
|
|
119
|
+
let ctx = this.#contexts.get(host);
|
|
120
|
+
if (!ctx) {
|
|
121
|
+
const leaf = authority.leaf(host);
|
|
122
|
+
ctx = tls.createSecureContext({ key: leaf.key.export({ type: "pkcs8", format: "pem" }), cert: leaf.pem });
|
|
123
|
+
this.#contexts.set(host, ctx);
|
|
124
|
+
}
|
|
125
|
+
return ctx;
|
|
126
|
+
}
|
|
127
|
+
/** `CONNECT host:443`: TLS to a stub for intercepted hosts, a plain tunnel for the rest. */
|
|
128
|
+
#tunnel(req, socket, head) {
|
|
129
|
+
// "host:port" or "[::1]:port". (Not via URL, which drops a default port such as 80.)
|
|
130
|
+
const at = (req.url ?? "").lastIndexOf(":");
|
|
131
|
+
const host = (req.url ?? "").slice(0, at).replace(/^\[|\]$/g, "").toLowerCase();
|
|
132
|
+
const port = (req.url ?? "").slice(at + 1);
|
|
133
|
+
socket.on("error", () => { });
|
|
134
|
+
const route = lookupHost(this.routes, host);
|
|
135
|
+
if (route) {
|
|
136
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
137
|
+
if (head.length)
|
|
138
|
+
socket.unshift(head);
|
|
139
|
+
// Some clients tunnel plain HTTP too (Node does with NODE_USE_ENV_PROXY): port 80 stays plain.
|
|
140
|
+
if (port === "80")
|
|
141
|
+
return route.attach(socket);
|
|
142
|
+
const secure = new tls.TLSSocket(socket, { isServer: true, secureContext: this.#context(host), ALPNProtocols: ["http/1.1"] });
|
|
143
|
+
secure.on("error", () => socket.destroy());
|
|
144
|
+
route.attach(secure);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (this.offline) {
|
|
148
|
+
this.blocked.add(host);
|
|
149
|
+
socket.end("HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.passedThrough.add(host);
|
|
153
|
+
const upstream = net.connect(Number(port) || 443, host, () => {
|
|
154
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
155
|
+
if (head.length)
|
|
156
|
+
upstream.write(head);
|
|
157
|
+
upstream.pipe(socket).pipe(upstream);
|
|
158
|
+
});
|
|
159
|
+
upstream.on("error", () => socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"));
|
|
160
|
+
}
|
|
161
|
+
/** A plain-HTTP proxy request (`GET http://host/path`). */
|
|
162
|
+
#plain(req, res) {
|
|
163
|
+
let target;
|
|
164
|
+
try {
|
|
165
|
+
target = new URL(req.url ?? "");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
res.writeHead(400).end("slicetest proxy: expected an absolute URL");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const host = target.hostname.toLowerCase();
|
|
172
|
+
const route = lookupHost(this.routes, host);
|
|
173
|
+
if (!route && this.offline) {
|
|
174
|
+
this.blocked.add(host);
|
|
175
|
+
res.writeHead(403, { "content-type": "text/plain" }).end(`slicetest: offline, ${host} is not stubbed`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!route)
|
|
179
|
+
this.passedThrough.add(host);
|
|
180
|
+
const forward = http.request({
|
|
181
|
+
method: req.method,
|
|
182
|
+
path: target.pathname + target.search,
|
|
183
|
+
headers: req.headers,
|
|
184
|
+
...(route ? { host: "127.0.0.1", port: route.port } : { host, port: Number(target.port || 80) }),
|
|
185
|
+
}, (upstream) => {
|
|
186
|
+
res.writeHead(upstream.statusCode ?? 502, upstream.headers);
|
|
187
|
+
upstream.pipe(res);
|
|
188
|
+
});
|
|
189
|
+
forward.on("error", () => res.headersSent || res.writeHead(502).end());
|
|
190
|
+
req.pipe(forward);
|
|
191
|
+
}
|
|
192
|
+
async close() {
|
|
193
|
+
for (const s of this.#sockets)
|
|
194
|
+
s.destroy();
|
|
195
|
+
await new Promise((resolve) => this.#server.close(resolve));
|
|
196
|
+
// On Windows a client still shutting down may hold the trust store open for a moment.
|
|
197
|
+
await rm(this.files.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => { });
|
|
198
|
+
}
|
|
199
|
+
}
|
package/dist/neon.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The made-up domain of the connection string the app gets; `*.` of it is intercepted. */
|
|
2
|
+
export declare const NEON_DOMAIN = "neon.slicetest";
|
|
3
|
+
/** `url` (the real test database) with its host replaced by a Neon-style endpoint host. */
|
|
4
|
+
export declare function neonUrl(url: string): string;
|
|
5
|
+
export declare class NeonEndpoint {
|
|
6
|
+
#private;
|
|
7
|
+
private constructor();
|
|
8
|
+
/** `databaseUrl` is where queries really run: the worker database, or the `db.queries` proxy in front of it. */
|
|
9
|
+
static start(databaseUrl: string): Promise<NeonEndpoint>;
|
|
10
|
+
get port(): number;
|
|
11
|
+
/** Serve a connection the intercepting proxy terminated. */
|
|
12
|
+
attach(socket: import("node:net").Socket): void;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|