Files
kandev/apps/web/e2e/helpers/plugin-git-credentials.ts
T
2026-08-15 02:28:10 +01:00

341 lines
12 KiB
TypeScript

import { execFileSync, spawn, spawnSync } from "node:child_process";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createServer as createTLSServer, type Server as TLSServer } from "node:https";
import { connect } from "node:net";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pipeline, type Duplex, type Readable, type Writable } from "node:stream";
export const fixtureGitProvider = "fixture-source-control";
export const fixtureGitHost = "bitbucket.example.test";
export const fixtureGitPath = "/scm/TEAM/fixture";
export const fixtureGitURL = `https://${fixtureGitHost}${fixtureGitPath}`;
export const fixtureGitUser = "fixture-user";
export const fixtureGitSecret = "fixture-credential-secret";
const fixturePluginID = "kandev-plugin-e2e";
const fixturePluginPackage = path.resolve(
__dirname,
"../../../backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
);
type GitHTTPFixture = {
checkoutPath: string;
proxyURL: string;
profileGitEnv: Array<{ key: string; value: string }>;
requestPaths: () => string[];
requestHosts: () => string[];
pushed: () => boolean;
close: () => Promise<void>;
};
/**
* A real HTTPS Git server which insists on Basic auth, reached through a
* bridge-visible CONNECT proxy. The remote URL remains the fixture provider's
* exact HTTPS identity, so the credential helper receives the host/path that
* its broker lease was issued for rather than a test-server substitute.
*/
export async function startPluginGitFixture(root: string): Promise<GitHTTPFixture> {
const repoRoot = path.join(root, "credential-git");
const bareRepo = path.join(repoRoot, fixtureGitPath);
const checkoutPath = path.join(root, "credential-git-checkout");
fs.mkdirSync(path.dirname(bareRepo), { recursive: true });
fs.mkdirSync(checkoutPath, { recursive: true });
execFileSync("git", ["init", "--bare", "-b", "main", bareRepo]);
execFileSync("git", ["init", "-b", "main"], { cwd: checkoutPath });
fs.writeFileSync(path.join(checkoutPath, "README.md"), "credential fixture\n");
execFileSync("git", ["add", "."], { cwd: checkoutPath });
execFileSync(
"git",
["-c", "user.name=E2E", "-c", "user.email=e2e@test.local", "commit", "-m", "initial"],
{ cwd: checkoutPath },
);
execFileSync("git", ["remote", "add", "origin", bareRepo], { cwd: checkoutPath });
execFileSync("git", ["push", "origin", "main"], { cwd: checkoutPath });
execFileSync("git", ["remote", "set-url", "origin", fixtureGitURL], { cwd: checkoutPath });
const certDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-e2e-git-cert-"));
const keyPath = path.join(certDir, "key.pem");
const certPath = path.join(certDir, "cert.pem");
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
keyPath,
"-out",
certPath,
"-subj",
`/CN=${fixtureGitHost}`,
"-days",
"1",
],
{ stdio: process.env.E2E_DEBUG ? "inherit" : "ignore" },
);
const requestPaths: string[] = [];
const requestHosts: string[] = [];
const gitServer = createTLSServer(
{ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) },
(req, res) => {
requestPaths.push(new URL(req.url ?? "/", "https://fixture").pathname);
requestHosts.push(req.headers.host ?? "");
if (!requestHasFixtureAuth(req)) {
res.writeHead(401, { "www-authenticate": 'Basic realm="fixture"' }).end();
return;
}
serveGitHTTP(req, res, repoRoot);
},
);
const gitPort = await listen(gitServer);
const proxy = createFixtureConnectProxy(gitPort);
const proxyPort = await listen(proxy);
const gateway = dockerBridgeGateway();
const proxyURL = `http://${gateway}:${proxyPort}`;
return {
checkoutPath,
proxyURL,
profileGitEnv: [
{ key: "HTTPS_PROXY", value: proxyURL },
{ key: "GIT_SSL_NO_VERIFY", value: "true" },
],
requestPaths: () => [...requestPaths],
requestHosts: () => [...requestHosts],
pushed: () =>
execFileSync("git", ["--git-dir", bareRepo, "log", "-1", "--format=%s"], {
encoding: "utf8",
}).trim() === "credential fixture push",
close: async () => {
await closeServer(proxy);
await closeServer(gitServer);
fs.rmSync(certDir, { recursive: true, force: true });
},
};
}
export function credentialBrokerPublicURL(backendPort: number): string | undefined {
const raw = process.env.KANDEV_E2E_CREDENTIAL_BROKER_PUBLIC_BASE_URL;
return raw?.trim().replace("{port}", String(backendPort)) || undefined;
}
export async function installFixturePlugin(baseURL: string): Promise<void> {
if (!fs.existsSync(fixturePluginPackage)) {
throw new Error(`fixture plugin package missing: ${fixturePluginPackage}`);
}
const response = await fetch(`${baseURL}/api/plugins/install`, {
method: "POST",
body: (() => {
const form = new FormData();
form.append(
"package",
new Blob([fs.readFileSync(fixturePluginPackage)], { type: "application/gzip" }),
path.basename(fixturePluginPackage),
);
return form;
})(),
});
if (!response.ok) throw new Error(`fixture plugin install failed (${response.status})`);
}
export async function uninstallFixturePlugin(baseURL: string): Promise<void> {
await fetch(`${baseURL}/api/plugins/${fixturePluginID}`, { method: "DELETE" });
}
export async function revokeFixtureConnection(
baseURL: string,
workspaceId: string,
): Promise<Response> {
return fetch(`${baseURL}/api/plugins/${fixturePluginID}/actions/connection-status`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workspaceId, body: { revoke: true } }),
});
}
export function assertExactFixtureTransport(fixture: GitHTTPFixture): void {
const paths = fixture.requestPaths();
const hosts = fixture.requestHosts();
if (paths.length === 0 || paths.some((requestPath) => !requestPath.startsWith(fixtureGitPath))) {
throw new Error("Git did not request the exact fixture repository path");
}
if (hosts.some((host) => host !== fixtureGitHost)) {
throw new Error("Git did not preserve the exact fixture repository host");
}
}
export function runDockerGit(
containerID: string,
command: string,
): Promise<{ output: string; status: number }> {
return runProcess("docker", ["exec", containerID, "sh", "-lc", command]);
}
export function runRemoteGit(
agentctlPID: number,
command: string,
targetName: string,
): Promise<{
output: string;
status: number;
}> {
return runProcess("docker", [
"exec",
"--user",
"kandev",
targetName,
"sh",
"-lc",
`xargs -0 sh -c 'env "$@" sh -lc "$0"' ${shellQuote(command)} < /proc/${agentctlPID}/environ`,
]);
}
function runProcess(command: string, args: string[]): Promise<{ output: string; status: number }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args);
const chunks: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
child.stderr.on("data", (chunk: Buffer) => chunks.push(chunk));
child.once("error", reject);
child.once("close", (status) => {
resolve({ output: Buffer.concat(chunks).toString(), status: status ?? 1 });
});
});
}
function requestHasFixtureAuth(req: IncomingMessage): boolean {
const expected = `Basic ${Buffer.from(`${fixtureGitUser}:${fixtureGitSecret}`).toString("base64")}`;
return req.headers.authorization === expected;
}
function serveGitHTTP(req: IncomingMessage, res: ServerResponse, root: string): void {
const parsed = new URL(req.url ?? "/", "https://fixture");
const child = spawn("git", ["http-backend"], {
env: {
...process.env,
GIT_PROJECT_ROOT: root,
GIT_HTTP_EXPORT_ALL: "1",
PATH_INFO: parsed.pathname,
QUERY_STRING: parsed.search.slice(1),
REQUEST_METHOD: req.method ?? "GET",
CONTENT_TYPE: req.headers["content-type"] ?? "",
CONTENT_LENGTH: req.headers["content-length"] ?? "",
REMOTE_USER: fixtureGitUser,
},
});
const chunks: Buffer[] = [];
let failed = false;
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
child.once("error", () => {
failed = true;
res.writeHead(500).end();
});
child.once("close", () => {
if (!failed) writeCGIResponse(res, Buffer.concat(chunks));
});
pipeFixtureGitRequest(req, child.stdin, () => {
if (failed) return;
failed = true;
child.kill();
if (!res.destroyed) res.destroy();
});
}
/** Pipes an HTTP request into git-http-backend with deterministic EPIPE handling. */
export function pipeFixtureGitRequest(
request: Readable,
childInput: Writable,
onError: (error: Error) => void,
): void {
pipeline(request, childInput, (error) => {
if (error) onError(error);
});
}
function writeCGIResponse(res: ServerResponse, body: Buffer): void {
const splitAt = body.indexOf("\r\n\r\n");
if (splitAt < 0) {
res.writeHead(500).end();
return;
}
let status = 200;
const headers: Record<string, string> = {};
for (const line of body.subarray(0, splitAt).toString().split("\r\n")) {
const [name, value] = line.split(/:\s*/, 2);
if (name.toLowerCase() === "status") status = Number(value?.split(" ")[0]) || 500;
else if (name && value) headers[name] = value;
}
res.writeHead(status, headers).end(body.subarray(splitAt + 4));
}
function createFixtureConnectProxy(gitPort: number): Server {
const proxy = createServer((_, res) => res.writeHead(405).end());
proxy.on("connect", (_req, client, head) => {
const upstream = connect(gitPort, "127.0.0.1", () => {
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length > 0) upstream.write(head);
bridgeFixtureConnectStreams(client, upstream);
});
bindFixtureConnectErrors(client, upstream);
});
return proxy;
}
export function bindFixtureConnectErrors(client: Duplex, upstream: Duplex): void {
const destroyBoth = () => destroyFixtureConnectStreams(client, upstream);
client.once("error", destroyBoth);
upstream.once("error", destroyBoth);
}
export function bridgeFixtureConnectStreams(client: Duplex, upstream: Duplex): void {
const destroyBoth = () => destroyFixtureConnectStreams(client, upstream);
pipeline(client, upstream, destroyBoth);
pipeline(upstream, client, destroyBoth);
}
function destroyFixtureConnectStreams(client: Duplex, upstream: Duplex): void {
client.destroy();
upstream.destroy();
}
function dockerBridgeGateway(): string {
const result = spawnSync(
"docker",
["network", "inspect", "bridge", "-f", "{{(index .IPAM.Config 0).Gateway}}"],
{ encoding: "utf8" },
);
const gateway = result.status === 0 ? result.stdout.trim() : "";
if (!gateway) throw new Error("could not determine Docker bridge gateway");
return gateway;
}
function listen(server: Server | TLSServer): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "0.0.0.0", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("fixture server did not receive a TCP port"));
return;
}
resolve(address.port);
});
});
}
function closeServer(server: Server | TLSServer): Promise<void> {
return new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}