164 lines
6.7 KiB
TypeScript
164 lines
6.7 KiB
TypeScript
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { expect, test } from "vitest";
|
|
import { loadConfig } from "../src/config.js";
|
|
import {
|
|
PiManagementError,
|
|
createPiManagement,
|
|
type PiExecFile,
|
|
} from "../src/pi/management.js";
|
|
|
|
function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-management-")), "settings.json")) {
|
|
return loadConfig({
|
|
THT_HARNESS_DIR: "../harness",
|
|
SETTINGS_FILE: settingsFile,
|
|
PI_BIN: "/usr/local/bin/pi",
|
|
PI_MANAGEMENT_TIMEOUT_MS: "750",
|
|
});
|
|
}
|
|
|
|
const supportedModels = [
|
|
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
|
|
{ provider: "deepseek", id: "deepseek-v4", name: "DeepSeek V4", reasoning: true },
|
|
];
|
|
|
|
function successfulExec(calls: Array<{ command: string; args: string[]; timeout: number }>): PiExecFile {
|
|
return async (command, args, options) => {
|
|
calls.push({ command, args, timeout: options.timeout });
|
|
return { stdout: "pi 0.80.3\n", stderr: "" };
|
|
};
|
|
}
|
|
|
|
// Catches a Pi executable that emits unexpected text or is invoked through a shell, which could
|
|
// turn a version display into a command-injection or information-disclosure surface.
|
|
test("status parses only a Pi version from a fixed execFile argument array", async () => {
|
|
const calls: Array<{ command: string; args: string[]; timeout: number }> = [];
|
|
const service = createPiManagement(configFor(), {
|
|
execute: successfulExec(calls),
|
|
listModels: async () => supportedModels,
|
|
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
|
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
|
});
|
|
|
|
await expect(service.status()).resolves.toEqual({
|
|
version: "0.80.3",
|
|
ready: true,
|
|
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
|
checkedAt: "2026-08-05T10:00:00.000Z",
|
|
});
|
|
expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]);
|
|
});
|
|
|
|
// Catches an options response that leaks provider metadata or lets callers choose model IDs that
|
|
// Pi did not explicitly enable for this installation.
|
|
test("options expose only closed provider, model, and reasoning choices", async () => {
|
|
const service = createPiManagement(configFor(), {
|
|
execute: successfulExec([]),
|
|
listModels: async () => supportedModels,
|
|
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
|
});
|
|
|
|
await expect(service.options()).resolves.toEqual({
|
|
providers: ["zai", "deepseek"],
|
|
models: [
|
|
{ provider: "zai", id: "glm-5.2" },
|
|
{ provider: "deepseek", id: "deepseek-v4" },
|
|
],
|
|
reasoning: ["low", "medium", "high"],
|
|
checkedAt: "2026-08-05T10:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
// Catches configuration writes that accept whitespace, unknown choices, or extra free-form fields
|
|
// before reaching the durable installation settings file.
|
|
test("config rejects invalid free-form values before writing settings", async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-invalid-"));
|
|
try {
|
|
let writes = 0;
|
|
const service = createPiManagement(configFor(join(directory, "settings.json")), {
|
|
execute: successfulExec([]),
|
|
listModels: async () => supportedModels,
|
|
readSettings: () => ({}),
|
|
saveSettings: () => { writes += 1; return {}; },
|
|
});
|
|
|
|
await expect(service.configure({
|
|
provider: "zai ", model: "glm-5.2", reasoning: "medium", unexpected: "value",
|
|
} as any)).rejects.toMatchObject<PiManagementError>({ code: "pi_management_invalid_config" });
|
|
expect(writes).toBe(0);
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Catches a non-atomic implementation that can leave partial settings or temporary files after a
|
|
// normal installation-default update.
|
|
test("config validates closed choices and atomically persists non-secret defaults", async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-write-"));
|
|
const settingsFile = join(directory, "settings.json");
|
|
try {
|
|
const service = createPiManagement(configFor(settingsFile), {
|
|
execute: successfulExec([]),
|
|
listModels: async () => supportedModels,
|
|
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
|
});
|
|
|
|
await expect(service.configure({
|
|
provider: "zai", model: "glm-5.2", reasoning: "high",
|
|
})).resolves.toEqual({
|
|
provider: "zai", model: "glm-5.2", reasoning: "high", updatedAt: "2026-08-05T10:00:00.000Z",
|
|
});
|
|
expect(JSON.parse(readFileSync(settingsFile, "utf8"))).toEqual({
|
|
provider: "zai", model: "glm-5.2", thinking: "high",
|
|
});
|
|
expect(readdirSync(directory)).toEqual(["settings.json"]);
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Catches a hung Pi smoke check that leaves an operator waiting indefinitely or returns raw child
|
|
// diagnostics containing provider credentials.
|
|
test("smoke uses the configured timeout and reports a sanitized timeout", async () => {
|
|
const calls: Array<{ command: string; args: string[]; timeout: number }> = [];
|
|
const service = createPiManagement(configFor(), {
|
|
execute: async (command, args, options) => {
|
|
calls.push({ command, args, timeout: options.timeout });
|
|
throw Object.assign(new Error("provider token=raw-provider-token"), { code: "ETIMEDOUT" });
|
|
},
|
|
listModels: async () => supportedModels,
|
|
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
|
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
|
});
|
|
|
|
await expect(service.test()).resolves.toEqual({
|
|
ready: false,
|
|
message: "Pi smoke check timed out",
|
|
checkedAt: "2026-08-05T10:00:00.000Z",
|
|
});
|
|
expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]);
|
|
});
|
|
|
|
// Catches an unbounded diagnostics endpoint or one that returns bearer tokens and connection
|
|
// passwords captured in Pi output.
|
|
test("logs keep only the latest 200 redacted lines", async () => {
|
|
const source = Array.from({ length: 205 }, (_, index) => `line-${index + 1}`);
|
|
source[203] = "Authorization: Bearer raw-bearer-token";
|
|
source[204] = "database_url=postgres://thoth:raw-db-password@example.invalid/db";
|
|
const service = createPiManagement(configFor(), {
|
|
execute: successfulExec([]),
|
|
listModels: async () => supportedModels,
|
|
readLogs: () => source.join("\n"),
|
|
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
|
});
|
|
|
|
const logs = await service.logs();
|
|
expect(logs.checkedAt).toBe("2026-08-05T10:00:00.000Z");
|
|
expect(logs.lines).toHaveLength(200);
|
|
expect(logs.lines[0]).toBe("line-6");
|
|
expect(logs.lines.join("\n")).not.toContain("raw-bearer-token");
|
|
expect(logs.lines.join("\n")).not.toContain("raw-db-password");
|
|
expect(logs.lines.join("\n")).toContain("[REDACTED]");
|
|
});
|