Files
ThothII/backend/test/windows-auth-storage.test.ts
T

193 lines
7.9 KiB
TypeScript

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 }> = [];
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("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");
});
});