Files
oh-my-pi/packages/coding-agent/test/git-subprocess-safety.test.ts
T

107 lines
3.3 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
import type { Subprocess } from "bun";
type SpawnOptions = Bun.SpawnOptions.SpawnOptions<
Bun.SpawnOptions.Writable,
Bun.SpawnOptions.Readable,
Bun.SpawnOptions.Readable
>;
function createTextStream(text: string): ReadableStream<Uint8Array> {
const body = new Response(text).body;
if (!body) throw new Error("Failed to create response stream.");
return body;
}
function createFakeProcess(stdout = "", stderr = "", exitCode = 0, exited?: Promise<number>): Subprocess {
return {
pid: 12345,
stdout: createTextStream(stdout),
stderr: createTextStream(stderr),
exited: exited ?? Promise.resolve(exitCode),
kill: vi.fn(),
} as unknown as Subprocess;
}
function createSpawnMock(factory: () => Subprocess, calls?: SpawnOptions[]) {
function mockSpawn(options: SpawnOptions & { cmd: string[] }): Subprocess;
function mockSpawn(cmd: string[], options?: SpawnOptions): Subprocess;
function mockSpawn(first: string[] | (SpawnOptions & { cmd: string[] }), second?: SpawnOptions): Subprocess {
if (calls) {
if (Array.isArray(first)) {
calls.push(second ?? {});
} else {
const { cmd, ...options } = first;
void cmd;
calls.push(options);
}
}
return factory();
}
return mockSpawn;
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("git subprocess safety", () => {
it("passes non-interactive credential env to git", async () => {
const calls: SpawnOptions[] = [];
vi.spyOn(Bun, "spawn").mockImplementation(createSpawnMock(() => createFakeProcess(), calls));
await git.push("/work/pi");
expect(calls[0]?.env?.GIT_TERMINAL_PROMPT).toBe("0");
expect(calls[0]?.env?.GIT_ASKPASS).toBeDefined();
expect(calls[0]?.env?.SSH_ASKPASS).toBeDefined();
expect(calls[0]?.env?.GPG_TTY).toBe("not a tty");
});
it("bounds captured stdout", async () => {
const tooLarge = "x".repeat(git.GIT_COMMAND_OUTPUT_LIMIT_BYTES + 1);
vi.spyOn(Bun, "spawn").mockImplementation(createSpawnMock(() => createFakeProcess(tooLarge)));
const output = await git.show("/work/pi", "HEAD");
expect(output.length).toBeLessThanOrEqual(git.GIT_COMMAND_OUTPUT_LIMIT_BYTES + 200);
expect(output).toContain("truncated");
});
it("kills git commands that exceed the subprocess timeout before returning", async () => {
vi.useFakeTimers();
const exited = Promise.withResolvers<number>();
const child = createFakeProcess("", "", 0, exited.promise);
const kill = vi.fn((signal?: NodeJS.Signals) => {
if (signal === "SIGKILL") exited.resolve(1);
return true;
});
child.kill = kill;
vi.spyOn(Bun, "spawn").mockImplementation(createSpawnMock(() => child));
const failure = git.push("/work/pi").then(
() => undefined,
error => error,
);
vi.advanceTimersByTime(git.GIT_COMMAND_TIMEOUT_MS);
await flushMicrotasks();
expect(kill).toHaveBeenCalledWith("SIGTERM");
vi.advanceTimersByTime(5_000);
await flushMicrotasks();
const error = await failure;
expect(kill).toHaveBeenCalledWith("SIGKILL");
expect(error).toBeInstanceOf(git.GitCommandError);
expect(String(error.message)).toContain("timed out");
});
});