305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
import { appendFileSync, chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { spawn } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { EventEmitter } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
|
|
|
|
const root = "C:\\ProgramData\\ThothII\\auth";
|
|
const filename = "a".repeat(64) + ".json";
|
|
const realChildFixture = fileURLToPath(new URL("./fixtures/windows-auth-storage-real-child.mjs", import.meta.url));
|
|
const fixtureRoots: string[] = [];
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
|
|
async function waitForMarker(marker: string, expected: string): Promise<void> {
|
|
const deadline = Date.now() + 3_000;
|
|
while (Date.now() < deadline) {
|
|
if (existsSync(marker) && readFileSync(marker, "utf8").includes(expected)) return;
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error(`real helper marker did not contain ${expected}`);
|
|
}
|
|
|
|
function realChildBridge(mode: "timeout" | "stdout" | "stderr" | "stdin") {
|
|
const directory = mkdtempSync(join(tmpdir(), "thothii-auth-bridge-child-"));
|
|
fixtureRoots.push(directory);
|
|
const marker = join(directory, "marker.txt");
|
|
const launcher = join(directory, "tht.exe");
|
|
writeFileSync(launcher, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(realChildFixture)} ${shellQuote(mode)} ${shellQuote(marker)} "$@"\n`, { mode: 0o700 });
|
|
chmodSync(launcher, 0o700);
|
|
return {
|
|
marker,
|
|
bridge: createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
spawnChild: (_executable, args, options) => spawn(launcher, [...args], options),
|
|
...(mode === "stdin" ? {
|
|
beforeInputForTest: async () => {
|
|
await waitForMarker(marker, "stdin-closed");
|
|
appendFileSync(marker, "before-input\n");
|
|
},
|
|
} : {}),
|
|
} as never),
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const directory of fixtureRoots.splice(0)) {
|
|
const marker = join(directory, "marker.txt");
|
|
try {
|
|
const pid = Number(/^started:(\d+)$/m.exec(readFileSync(marker, "utf8"))?.[1]);
|
|
if (Number.isSafeInteger(pid) && pid > 0) process.kill(pid, "SIGKILL");
|
|
} catch { /* the test-owned child already exited or did not start */ }
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
class FakeBridgeChild extends EventEmitter {
|
|
readonly stdin = new PassThrough();
|
|
readonly stdout = new PassThrough();
|
|
readonly stderr = new PassThrough();
|
|
readonly kill = vi.fn(() => true);
|
|
|
|
close(code = 0, signal: NodeJS.Signals | null = null): void {
|
|
this.emit("close", code, signal);
|
|
}
|
|
}
|
|
|
|
function bridgeForChild(child: FakeBridgeChild) {
|
|
const spawnChild = vi.fn(() => child);
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
spawnChild,
|
|
} as never);
|
|
return { bridge, spawnChild };
|
|
}
|
|
|
|
describe("Windows auth-storage bridge", () => {
|
|
test("uses hidden tht argv and sends record bytes only over bounded stdin", async () => {
|
|
const calls: Array<{ executable: string; args: readonly string[]; input: Buffer; timeoutMs: number }> = [];
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\Program Files\\ThothII\\tht.exe",
|
|
invoke: async (call) => {
|
|
calls.push(call);
|
|
return { code: 0, stdout: Buffer.from('{"version":1,"ok":true,"created":true}\n'), stderr: Buffer.alloc(0) };
|
|
},
|
|
});
|
|
|
|
await expect(bridge.create(root, "sessions", filename, Buffer.from('{"subject":"record-data"}'))).resolves.toBe(true);
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]).toMatchObject({
|
|
executable: "C:\\Program Files\\ThothII\\tht.exe",
|
|
args: ["_auth-storage"],
|
|
});
|
|
expect(JSON.stringify(calls[0].args)).not.toContain("record-data");
|
|
expect(JSON.parse(calls[0].input.toString("utf8"))).toMatchObject({
|
|
version: 1,
|
|
operation: "create",
|
|
root,
|
|
directory: "sessions",
|
|
filename,
|
|
contentBase64: Buffer.from('{"subject":"record-data"}').toString("base64"),
|
|
});
|
|
expect(calls[0].timeoutMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
test.each([
|
|
{ label: "nonzero", result: { code: 1, stdout: Buffer.from('{"version":1,"ok":true}\n'), stderr: Buffer.from("secret") } },
|
|
{ label: "malformed stdout", result: { code: 0, stdout: Buffer.from("not-json"), stderr: Buffer.alloc(0) } },
|
|
{ label: "unexpected stdout", result: { code: 0, stdout: Buffer.from('{"version":1,"ok":true}\nextra'), stderr: Buffer.alloc(0) } },
|
|
{ label: "unexpected JSON field", result: { code: 0, stdout: Buffer.from('{"version":1,"ok":true,"created":true,"detail":"secret"}\n'), stderr: Buffer.alloc(0) } },
|
|
])("fails closed on $label bridge output", async ({ result }) => {
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async () => result,
|
|
});
|
|
|
|
await expect(bridge.create(root, "sessions", filename, Buffer.from("record")))
|
|
.rejects.toThrow("auth_session_store_invalid");
|
|
});
|
|
|
|
test("fails closed on a bridge timeout without disclosing request content", async () => {
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async () => { throw new Error("timeout secret-record"); },
|
|
});
|
|
|
|
await expect(bridge.create(root, "sessions", filename, Buffer.from("secret-record")))
|
|
.rejects.toThrow("auth_session_store_invalid");
|
|
});
|
|
|
|
test("rejects a claimed-read response without bounded record bytes", async () => {
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async () => ({ code: 0, stdout: Buffer.from('{"version":1,"ok":true,"found":true}\n'), stderr: Buffer.alloc(0) }),
|
|
});
|
|
|
|
await expect(bridge.readClaim(root, filename)).rejects.toThrow("auth_session_store_invalid");
|
|
});
|
|
|
|
test("rejects an executable value that would require shell parsing", () => {
|
|
expect(() => createWindowsAuthStorageBridge({ thtExecutable: "tht.exe && unexpected" }))
|
|
.toThrow("auth_session_store_invalid");
|
|
});
|
|
|
|
test("parses the lower-camel list DTO emitted by the Go helper for a nonempty directory", async () => {
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async () => ({
|
|
code: 0,
|
|
// This is the raw JSON object emitted by authstorage.response after Go's DTO encoding.
|
|
stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${filename}","modifiedUnixMs":1893456245000}]}\n`),
|
|
stderr: Buffer.alloc(0),
|
|
}),
|
|
});
|
|
|
|
await expect(bridge.list(root, "oidc")).resolves.toEqual([
|
|
{ name: filename, modifiedUnixMs: 1_893_456_245_000 },
|
|
]);
|
|
});
|
|
|
|
test("parses the Go helper's required empty entries array", async () => {
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async () => ({
|
|
code: 0,
|
|
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'),
|
|
stderr: Buffer.alloc(0),
|
|
}),
|
|
});
|
|
|
|
await expect(bridge.list(root, "sessions")).resolves.toEqual([]);
|
|
});
|
|
|
|
test("allows only canonical OIDC claim removal and rejects claims elsewhere", async () => {
|
|
const claim = `${"b".repeat(64)}.claim`;
|
|
const invoke = vi.fn(async () => ({
|
|
code: 0,
|
|
stdout: Buffer.from('{"version":1,"ok":true,"removed":true}\n'),
|
|
stderr: Buffer.alloc(0),
|
|
}));
|
|
const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", invoke });
|
|
|
|
await expect(bridge.remove(root, "oidc", claim)).resolves.toBe(true);
|
|
await expect(bridge.remove(root, "sessions", claim)).rejects.toThrow("auth_session_store_invalid");
|
|
await expect(bridge.read(root, "oidc", claim)).rejects.toThrow("auth_session_store_invalid");
|
|
await expect(bridge.create(root, "oidc", claim, Buffer.from("record"))).rejects.toThrow("auth_session_store_invalid");
|
|
await expect(bridge.replace(root, "oidc", claim, Buffer.from("record"))).rejects.toThrow("auth_session_store_invalid");
|
|
await expect(bridge.remove(root, "oidc", `../${claim}`)).rejects.toThrow("auth_session_store_invalid");
|
|
await expect(bridge.remove(root, "oidc", `${claim}.bak`)).rejects.toThrow("auth_session_store_invalid");
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("rejects OIDC claim entries returned for a sessions list", async () => {
|
|
const claim = `${"c".repeat(64)}.claim`;
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async () => ({
|
|
code: 0,
|
|
stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${claim}","modifiedUnixMs":1}]}\n`),
|
|
stderr: Buffer.alloc(0),
|
|
}),
|
|
});
|
|
|
|
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
|
|
});
|
|
|
|
test("aborts a stdin-closed looping helper on timeout and waits for close", async () => {
|
|
vi.useFakeTimers();
|
|
const child = new FakeBridgeChild();
|
|
const { bridge, spawnChild } = bridgeForChild(child);
|
|
const pending = bridge.list(root, "sessions");
|
|
const outcome = pending.then(() => "resolved", () => "rejected");
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
expect(spawnChild).toHaveBeenCalledOnce();
|
|
expect(child.kill).toHaveBeenCalledOnce();
|
|
expect(child.stdin.destroyed).toBe(true);
|
|
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
|
|
|
|
child.close();
|
|
await expect(outcome).resolves.toBe("rejected");
|
|
} finally {
|
|
child.close();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test.each(["stdout", "stderr"] as const)("aborts a %s-flooding helper and waits for close", async (stream) => {
|
|
const child = new FakeBridgeChild();
|
|
const { bridge, spawnChild } = bridgeForChild(child);
|
|
const pending = bridge.list(root, "sessions");
|
|
const outcome = pending.then(() => "resolved", () => "rejected");
|
|
await Promise.resolve();
|
|
expect(spawnChild).toHaveBeenCalledOnce();
|
|
|
|
child[stream].write(Buffer.alloc(64 * 1024 + 1));
|
|
await Promise.resolve();
|
|
expect(child.kill).toHaveBeenCalledOnce();
|
|
expect(child.stdin.destroyed).toBe(true);
|
|
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
|
|
|
|
child.close();
|
|
await expect(outcome).resolves.toBe("rejected");
|
|
});
|
|
|
|
test("aborts a helper when its stdin errors and waits for termination", async () => {
|
|
const child = new FakeBridgeChild();
|
|
const { bridge, spawnChild } = bridgeForChild(child);
|
|
const stdinErrored = new Promise<void>((resolve) => child.stdin.once("error", () => resolve()));
|
|
child.stdin.once("finish", () => child.stdin.destroy(new Error("stdin failure")));
|
|
const pending = bridge.list(root, "sessions");
|
|
const outcome = pending.then(() => "resolved", () => "rejected");
|
|
await Promise.resolve();
|
|
expect(spawnChild).toHaveBeenCalledOnce();
|
|
|
|
await stdinErrored;
|
|
expect(child.kill).toHaveBeenCalledOnce();
|
|
child.close();
|
|
await expect(outcome).resolves.toBe("rejected");
|
|
});
|
|
|
|
test("aborts an errored child exactly once and waits for its close event", async () => {
|
|
const child = new FakeBridgeChild();
|
|
const { bridge, spawnChild } = bridgeForChild(child);
|
|
const pending = bridge.list(root, "sessions");
|
|
const outcome = pending.then(() => "resolved", () => "rejected");
|
|
await Promise.resolve();
|
|
expect(spawnChild).toHaveBeenCalledOnce();
|
|
|
|
child.emit("error", new Error("helper error"));
|
|
child.emit("error", new Error("duplicate helper error"));
|
|
expect(child.kill).toHaveBeenCalledOnce();
|
|
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
|
|
child.close();
|
|
await expect(outcome).resolves.toBe("rejected");
|
|
});
|
|
|
|
test("fails closed when launching the helper throws before a child exists", async () => {
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
spawnChild: () => { throw new Error("launch detail must not escape"); },
|
|
});
|
|
|
|
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
|
|
});
|
|
|
|
test.each(["timeout", "stdout", "stderr", "stdin"] as const)("kills a real %s helper process through the production spawn path", async (mode) => {
|
|
const { bridge, marker } = realChildBridge(mode);
|
|
const startedAt = Date.now();
|
|
const pending = bridge.list(root, "sessions");
|
|
const outcome = pending.then(() => undefined, (error: unknown) => error);
|
|
await waitForMarker(marker, "started");
|
|
if (mode === "stdin") await waitForMarker(marker, "before-input");
|
|
|
|
await expect(outcome).resolves.toMatchObject({ message: "auth_session_store_invalid" });
|
|
await waitForMarker(marker, "terminated");
|
|
if (mode === "stdin") expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
}, 10_000);
|
|
});
|