fix(auth): harden Windows storage bridge
This commit is contained in:
@@ -1,9 +1,31 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { 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";
|
||||
|
||||
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 }> = [];
|
||||
@@ -71,4 +93,100 @@ describe("Windows auth-storage bridge", () => {
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user