mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-07 21:44:32 -05:00
Port the git server (:4070) used by the git-sync specs into misc/server: per-repo auth overrides, repo setup/teardown and push support, started from index.ts. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
import { execFileSync, spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import zlib from "node:zlib";
|
|
|
|
import backend from "git-http-backend";
|
|
|
|
const PORT = 4070;
|
|
const REPOS_ROOT = path.join(os.tmpdir(), "insomnia-git-mock-server");
|
|
|
|
const DEFAULT_AUTH = { enabled: true, username: "testuser", password: "testpass" };
|
|
|
|
// Auth overrides are keyed by repo name so a test that exercises bad
|
|
// credentials / disabled auth only affects the one repo it created (via
|
|
// the `gitRepo` fixture's unique UUID name) — never a repo any other test
|
|
// is using concurrently or afterward. Repos with no override fall back to
|
|
// DEFAULT_AUTH.
|
|
const authOverrides = new Map<string, { enabled: boolean; username: string; password: string }>(); // repoName -> { enabled, username, password }
|
|
|
|
function getAuth(repoName: string) {
|
|
return authOverrides.get(repoName) || DEFAULT_AUTH;
|
|
}
|
|
|
|
fs.mkdirSync(REPOS_ROOT, { recursive: true });
|
|
|
|
function repoDir(name: string) {
|
|
if (typeof name !== "string" || !name || name.includes("..")) {
|
|
throw new Error(`Invalid repo name: ${name}`);
|
|
}
|
|
return path.join(REPOS_ROOT, name.replace(/[^a-zA-Z0-9._-]/g, ""));
|
|
}
|
|
|
|
function readJsonBody(req: IncomingMessage): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
req.on("data", (chunk: any) => (data += chunk));
|
|
req.on("end", () => {
|
|
if (!data) return resolve({});
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function isAuthorized(req: IncomingMessage, repoName: string) {
|
|
const auth = getAuth(repoName);
|
|
if (!auth.enabled) return true;
|
|
const [scheme, encoded] = (req.headers.authorization || "").split(" ");
|
|
if (scheme !== "Basic" || !encoded) return false;
|
|
const [user, pass] = Buffer.from(encoded, "base64").toString().split(":");
|
|
return user === auth.username && pass === auth.password;
|
|
}
|
|
|
|
/** Creates a fresh bare repo, discarding any prior contents at that name. */
|
|
function initBareRepo(name: string) {
|
|
const dir = repoDir(name);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
execFileSync("git", ["init", "--bare", "-b", "master", dir]);
|
|
}
|
|
|
|
/**
|
|
* Seeds a bare repo with one empty commit on `master`, via a throwaway
|
|
* working clone. The commit is intentionally file-less: Insomnia's clone
|
|
* flow treats a repo with zero files as "Create Blank Project" rather than
|
|
* "Clone Project"/"Clone and Migrate", which is what these tests expect.
|
|
*/
|
|
function seedRepo(name: string) {
|
|
const dir = repoDir(name);
|
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "insomnia-git-seed-"));
|
|
try {
|
|
execFileSync("git", ["init", "-b", "master", workDir]);
|
|
execFileSync(
|
|
"git",
|
|
[
|
|
"-c",
|
|
"user.name=Mock Seed",
|
|
"-c",
|
|
"user.email=seed@example.com",
|
|
"commit",
|
|
"--allow-empty",
|
|
"-m",
|
|
"Initial commit",
|
|
],
|
|
{ cwd: workDir },
|
|
);
|
|
execFileSync("git", ["remote", "add", "origin", dir], { cwd: workDir });
|
|
execFileSync("git", ["push", "origin", "master"], { cwd: workDir });
|
|
} finally {
|
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Commits `content` at `filePath` on `branch` of a bare repo, via a
|
|
* throwaway working clone, creating `branch` from master when it doesn't
|
|
* exist yet. Simulates a second client pushing to the remote.
|
|
*/
|
|
function pushFile(
|
|
name: string,
|
|
{ branch, filePath, content, message }: { branch: string; filePath: string; content: string; message: string },
|
|
) {
|
|
const dir = repoDir(name);
|
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "insomnia-git-push-"));
|
|
const git = (...args: string[]) => execFileSync("git", args, { cwd: workDir });
|
|
try {
|
|
execFileSync("git", ["clone", dir, workDir]);
|
|
if (listBranches(name).includes(branch)) {
|
|
git("checkout", branch);
|
|
} else {
|
|
git("checkout", "-b", branch);
|
|
}
|
|
fs.writeFileSync(path.join(workDir, filePath), content);
|
|
git("add", filePath);
|
|
git(
|
|
"-c",
|
|
"user.name=Mock Remote",
|
|
"-c",
|
|
"user.email=remote@example.com",
|
|
"commit",
|
|
"-m",
|
|
message,
|
|
);
|
|
git("push", "origin", `HEAD:refs/heads/${branch}`);
|
|
} finally {
|
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Lists local branch names in a bare repo, e.g. ["master", "branch1"]. */
|
|
function listBranches(name: string) {
|
|
const dir = repoDir(name);
|
|
const output = execFileSync("git", [
|
|
"--git-dir",
|
|
dir,
|
|
"branch",
|
|
"--format=%(refname:short)",
|
|
]).toString();
|
|
return output
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Lists commits reachable from `branch` in a bare repo, newest first, as
|
|
* `{ id, message, authorName, authorEmail }`. Returns `[]` if the branch
|
|
* doesn't exist rather than throwing, since "not pushed yet" is a normal
|
|
* state for tests to check.
|
|
*/
|
|
function listCommits(name: string, branch: string) {
|
|
const dir = repoDir(name);
|
|
const format = "%H%x1f%s%x1f%an%x1f%ae";
|
|
let output: string;
|
|
try {
|
|
output = execFileSync("git", [
|
|
"--git-dir",
|
|
dir,
|
|
"log",
|
|
branch,
|
|
`--format=${format}`,
|
|
]).toString();
|
|
} catch {
|
|
return [];
|
|
}
|
|
return output
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [id, message, authorName, authorEmail] = line.split("\u001F");
|
|
return { id, message, authorName, authorEmail };
|
|
});
|
|
}
|
|
|
|
async function handleAdmin(req: IncomingMessage, res: ServerResponse, url: URL) {
|
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
|
|
if (req.method === "GET" && segments.length === 1) {
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
// Server-wide reset — intended for a one-off cleanup (e.g. global setup),
|
|
// not per-test cleanup: it wipes every repo, including ones other tests
|
|
// may still be relying on. Specs should DELETE their own repo instead.
|
|
if (
|
|
req.method === "POST" &&
|
|
segments[1] === "reset" &&
|
|
segments.length === 2
|
|
) {
|
|
fs.rmSync(REPOS_ROOT, { recursive: true, force: true });
|
|
fs.mkdirSync(REPOS_ROOT, { recursive: true });
|
|
authOverrides.clear();
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
if (segments[1] === "repos" && segments.length === 3) {
|
|
const name = decodeURIComponent(segments[2]);
|
|
if (req.method === "PUT" || req.method === "POST") {
|
|
const body = await readJsonBody(req);
|
|
initBareRepo(name);
|
|
if (body.seed) seedRepo(name);
|
|
return sendJson(res, 201, { name });
|
|
}
|
|
if (req.method === "DELETE") {
|
|
fs.rmSync(repoDir(name), { recursive: true, force: true });
|
|
authOverrides.delete(name);
|
|
return sendJson(res, 200, { name });
|
|
}
|
|
}
|
|
|
|
// Per-repo auth override, so a test exercising bad credentials / disabled
|
|
// auth only affects the one repo it created, never another test's repo.
|
|
if (
|
|
segments[1] === "repos" &&
|
|
segments.length === 4 &&
|
|
segments[3] === "auth" &&
|
|
req.method === "PUT"
|
|
) {
|
|
const name = decodeURIComponent(segments[2]);
|
|
const body = await readJsonBody(req);
|
|
const current = getAuth(name);
|
|
const next = {
|
|
enabled: body.enabled !== false,
|
|
username: body.username ?? current.username,
|
|
password: body.password ?? current.password,
|
|
};
|
|
authOverrides.set(name, next);
|
|
return sendJson(res, 200, next);
|
|
}
|
|
|
|
if (
|
|
req.method === "GET" &&
|
|
segments[1] === "repos" &&
|
|
segments.length === 4 &&
|
|
segments[3] === "branches"
|
|
) {
|
|
const name = decodeURIComponent(segments[2]);
|
|
return sendJson(res, 200, { branches: listBranches(name) });
|
|
}
|
|
|
|
if (
|
|
req.method === "GET" &&
|
|
segments[1] === "repos" &&
|
|
segments.length === 4 &&
|
|
segments[3] === "commits"
|
|
) {
|
|
const name = decodeURIComponent(segments[2]);
|
|
const branch = url.searchParams.get("branch") || "master";
|
|
return sendJson(res, 200, { commits: listCommits(name, branch) });
|
|
}
|
|
|
|
if (
|
|
req.method === "POST" &&
|
|
segments[1] === "repos" &&
|
|
segments.length === 4 &&
|
|
segments[3] === "files"
|
|
) {
|
|
const name = decodeURIComponent(segments[2]);
|
|
const body = await readJsonBody(req);
|
|
pushFile(name, {
|
|
branch: body.branch || "master",
|
|
filePath: body.path,
|
|
content: body.content,
|
|
message: body.message || "Remote commit",
|
|
});
|
|
return sendJson(res, 201, { ok: true });
|
|
}
|
|
|
|
sendJson(res, 404, { error: "not found" });
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url!, `http://localhost:${PORT}`);
|
|
|
|
if (url.pathname === "/" || url.pathname === "/health") {
|
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
res.end("ok\n");
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith("/_admin")) {
|
|
handleAdmin(req, res, url).catch((err) => {
|
|
console.error("[git-server]", err);
|
|
sendJson(res, 400, { error: "Bad request" });
|
|
});
|
|
return;
|
|
}
|
|
|
|
const repoName = decodeURIComponent(url.pathname.split("/")[1] || "");
|
|
|
|
if (!isAuthorized(req, repoName)) {
|
|
res.writeHead(401, {
|
|
"WWW-Authenticate": 'Basic realm="git"',
|
|
"Content-Type": "text/plain",
|
|
});
|
|
res.end("Unauthorized\n");
|
|
return;
|
|
}
|
|
|
|
let dir: string;
|
|
try {
|
|
dir = repoDir(repoName);
|
|
} catch (err) {
|
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
console.error("[git-server]", err);
|
|
res.end("Internal server error");
|
|
return;
|
|
}
|
|
if (!fs.existsSync(dir)) {
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end("Repository not found\n");
|
|
return;
|
|
}
|
|
|
|
const reqStream =
|
|
req.headers["content-encoding"] === "gzip"
|
|
? req.pipe(zlib.createGunzip())
|
|
: req;
|
|
|
|
reqStream
|
|
.pipe(
|
|
backend(req.url!, (err: any, service: any) => {
|
|
if (err) {
|
|
res.statusCode = 500;
|
|
console.error("[git-server]", err);
|
|
res.end("Internal server error");
|
|
return;
|
|
}
|
|
res.setHeader("content-type", service.type);
|
|
const ps = spawn(service.cmd, service.args.concat(dir));
|
|
ps.stdout.pipe(service.createStream()).pipe(ps.stdin);
|
|
}),
|
|
)
|
|
.pipe(res);
|
|
});
|
|
|
|
export function startGitServer() {
|
|
server.listen(PORT, '127.0.0.1', () => {
|
|
process.stdout.write(
|
|
`Git mock server listening at http://localhost:${PORT}\n`,
|
|
);
|
|
});
|
|
}
|