Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
396 lines
17 KiB
TypeScript
396 lines
17 KiB
TypeScript
import { test, expect, vi } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import {
|
|
chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { ThtRunner } from "../src/tht/tht-runner.js";
|
|
|
|
// Spy on child_process.spawn so we can capture the resolved argv (incl. -c config)
|
|
// that ThtRunner.run() builds, without launching a real process.
|
|
vi.mock("node:child_process", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
return {
|
|
...actual,
|
|
spawn: vi.fn(() => {
|
|
const ch: any = new EventEmitter();
|
|
ch.stdout = new EventEmitter();
|
|
ch.stderr = new EventEmitter();
|
|
queueMicrotask(() => {
|
|
ch.stdout.emit("data", Buffer.from('{"id":"x"}'));
|
|
ch.emit("close", 0);
|
|
});
|
|
return ch;
|
|
}),
|
|
};
|
|
});
|
|
import { spawn } from "node:child_process";
|
|
|
|
test("sessionNew parses id from JSON", async () => {
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
r.run = async () => ({ code: 0, stdout: '{"id":"2026-06-27-100000-x"}', stderr: "" });
|
|
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
|
|
});
|
|
|
|
test("session language uses public per-command CLI flags and the selected config", async () => {
|
|
const runner = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
(spawn as any).mockClear();
|
|
await runner.sessionNew({ question: "Pazienti", interactionLanguage: "en" });
|
|
expect((spawn as any).mock.calls[0][1]).toEqual([
|
|
"session", "new", "Pazienti", "--interaction-language", "en", "--json", "-c", "config/tht.yaml",
|
|
]);
|
|
await runner.ensureInteractionLanguage("s1");
|
|
expect((spawn as any).mock.calls[1][1]).toEqual([
|
|
"session", "ensure-interaction-language", "s1", "--json", "-c", "config/tht.yaml",
|
|
]);
|
|
});
|
|
|
|
test("searchPack persists retrieval context with session and workspace", async () => {
|
|
const calls: any[] = [];
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
r.run = async (args, workspace) => {
|
|
calls.push({ args, workspace });
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
};
|
|
await r.searchPack("domanda", "sid", "psd");
|
|
expect(calls).toEqual([{ args: ["search", "pack", "domanda", "--session", "sid"], workspace: "psd" }]);
|
|
});
|
|
|
|
test("run passes configured THT_DATA_ROOT and preserves the remaining environment", async () => {
|
|
const previousDataRoot = process.env.THT_DATA_ROOT;
|
|
const previousCa = process.env.NODE_EXTRA_CA_CERTS;
|
|
process.env.THT_DATA_ROOT = "/ambient";
|
|
process.env.NODE_EXTRA_CA_CERTS = "/certs/company-ca.pem";
|
|
try {
|
|
(spawn as any).mockClear();
|
|
const r = new ThtRunner({
|
|
thtBin: "/opt/venv/bin/tht",
|
|
harnessDir: "/app/harness",
|
|
configPath: "config/tht.yaml",
|
|
dataRoot: "/configured",
|
|
});
|
|
await r.run(["session", "list", "--json"]);
|
|
const [bin, , options] = (spawn as any).mock.calls[0];
|
|
expect(bin).toBe("/opt/venv/bin/tht");
|
|
expect(options.env).toMatchObject({
|
|
THT_DATA_ROOT: "/configured",
|
|
NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem",
|
|
});
|
|
} finally {
|
|
if (previousDataRoot === undefined) delete process.env.THT_DATA_ROOT;
|
|
else process.env.THT_DATA_ROOT = previousDataRoot;
|
|
if (previousCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
|
|
else process.env.NODE_EXTRA_CA_CERTS = previousCa;
|
|
}
|
|
});
|
|
|
|
test("run injects DWH/vector credentials from the mounted secret bundle", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "tht-runner-bundle-"));
|
|
const secret = join(dir, "thothii.secrets");
|
|
writeFileSync(secret, [
|
|
"THT_DWH_API_KEY=dwh-secret",
|
|
"THT_VEC_API_KEY=vector-reader-secret",
|
|
"THT_VEC_WRITE_API_KEY=vector-writer-secret",
|
|
"THT_CA=/run/secrets/ca-chain.pem",
|
|
"",
|
|
].join("\n"), { mode: 0o600 });
|
|
chmodSync(secret, 0o600);
|
|
try {
|
|
(spawn as any).mockClear();
|
|
const runner = new ThtRunner({
|
|
thtBin: "tht",
|
|
harnessDir: "/app/harness",
|
|
configPath: "config/tht.yaml",
|
|
secretsFile: secret,
|
|
} as any);
|
|
|
|
await runner.run(["session", "list", "--json"]);
|
|
|
|
const env = (spawn as any).mock.calls[0][2].env;
|
|
expect(env).toMatchObject({
|
|
THT_DWH_API_KEY: "dwh-secret",
|
|
THT_VEC_API_KEY: "vector-reader-secret",
|
|
THT_VEC_WRITE_API_KEY: "vector-writer-secret",
|
|
THT_CA: "/run/secrets/ca-chain.pem",
|
|
THT_SSL_CA: "/run/secrets/ca-chain.pem",
|
|
});
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("run omits ambient THT_DATA_ROOT when config does not provide one", async () => {
|
|
const previousDataRoot = process.env.THT_DATA_ROOT;
|
|
const previousCredential = process.env.PI_PROVIDER_API_KEY;
|
|
process.env.THT_DATA_ROOT = "/ambient-must-not-leak";
|
|
process.env.PI_PROVIDER_API_KEY = "still-inherited";
|
|
try {
|
|
(spawn as any).mockClear();
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
await r.run(["session", "list", "--json"]);
|
|
const options = (spawn as any).mock.calls[0][2];
|
|
expect(options.env).not.toHaveProperty("THT_DATA_ROOT");
|
|
expect(options.env.PI_PROVIDER_API_KEY).toBe("still-inherited");
|
|
} finally {
|
|
if (previousDataRoot === undefined) delete process.env.THT_DATA_ROOT;
|
|
else process.env.THT_DATA_ROOT = previousDataRoot;
|
|
if (previousCredential === undefined) delete process.env.PI_PROVIDER_API_KEY;
|
|
else process.env.PI_PROVIDER_API_KEY = previousCredential;
|
|
}
|
|
});
|
|
|
|
test("principal-bound tht child replaces stale principal env and omits an absent display name", async () => {
|
|
const saved = Object.fromEntries([
|
|
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
|
|
"THT_PRINCIPAL_PERMISSIONS",
|
|
].map((key) => [key, process.env[key]]));
|
|
Object.assign(process.env, {
|
|
THT_PRINCIPAL_ISSUER: "stale-issuer", THT_PRINCIPAL_SUBJECT: "stale-subject",
|
|
THT_PRINCIPAL_DISPLAY_NAME: "Stale Name", THT_PRINCIPAL_IS_ADMIN: "true",
|
|
THT_PRINCIPAL_PERMISSIONS: "pi.manage,unknown.permission",
|
|
});
|
|
try {
|
|
(spawn as any).mockClear();
|
|
const runner = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" })
|
|
.withPrincipal({
|
|
issuer: "portal", subject: "42", roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
|
});
|
|
await runner.run(["session", "list", "--json"]);
|
|
const env = (spawn as any).mock.calls[0][2].env;
|
|
expect(env).toMatchObject({
|
|
THT_PRINCIPAL_ISSUER: "portal", THT_PRINCIPAL_SUBJECT: "42", THT_PRINCIPAL_IS_ADMIN: "false",
|
|
THT_PRINCIPAL_PERMISSIONS: "session.use",
|
|
});
|
|
expect(env).not.toHaveProperty("THT_PRINCIPAL_DISPLAY_NAME");
|
|
} finally {
|
|
for (const [key, value] of Object.entries(saved)) {
|
|
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("run with exit != 0 propagates error with stderr", async () => {
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" });
|
|
await expect(r.sessionList()).rejects.toThrow(/boom/);
|
|
});
|
|
|
|
test("sessionNew with a missing workspace file fails loud (no silent default fallback)", async () => {
|
|
// harnessDir "/nope" has no workspaces/foo.yaml. Silently falling back to the default
|
|
// config would target the WRONG workspace (wrong DB, wrong sessions dir): must throw.
|
|
(spawn as any).mockClear();
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
|
await expect(r.sessionNew({ question: "q", workspace: "foo" }))
|
|
.rejects.toThrow(/workspace non trovato: workspaces\/foo\.yaml/);
|
|
expect((spawn as any).mock.calls).toHaveLength(0);
|
|
});
|
|
|
|
test("buildArgv appends -c AFTER the subcommand (never a global -c)", () => {
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
|
expect(r.buildArgv(["session", "list", "--json"])).toEqual([
|
|
"session", "list", "--json", "-c", "config/tht.yaml",
|
|
]);
|
|
});
|
|
|
|
test("buildArgv passes an absolute immutable snapshot after the tht subcommand", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "tht-runner-snapshot-"));
|
|
const snapshotRoot = join(root, "snapshots", "runtime");
|
|
mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 });
|
|
const r = new ThtRunner({
|
|
thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml", runtimeSnapshotRoot: snapshotRoot,
|
|
});
|
|
try {
|
|
const snapshot = r.createRuntimeSnapshot("language: en\n");
|
|
expect(lstatSync(snapshot).isFile()).toBe(true);
|
|
expect(lstatSync(snapshot).mode & 0o777).toBe(0o400);
|
|
expect(r.buildArgv(["session", "new"], snapshot)).toEqual([
|
|
"session", "new", "-c", snapshot,
|
|
]);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("absolute config paths must be unmodified runner-created snapshots", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "tht-runner-snapshot-"));
|
|
const snapshotRoot = join(root, "snapshots", "runtime");
|
|
const outside = join(root, "outside.yaml");
|
|
mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 });
|
|
writeFileSync(outside, "language: en\n");
|
|
const r = new ThtRunner({
|
|
thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml", runtimeSnapshotRoot: snapshotRoot,
|
|
});
|
|
try {
|
|
expect(() => r.buildArgv(["session", "new"], "/tmp/untrusted.yaml"))
|
|
.toThrow(/trusted runtime snapshot/i);
|
|
expect(() => r.buildArgv(["session", "new"], outside))
|
|
.toThrow(/trusted runtime snapshot/i);
|
|
|
|
const snapshot = r.createRuntimeSnapshot("language: en\n");
|
|
chmodSync(snapshot, 0o600);
|
|
writeFileSync(snapshot, "language: it\n");
|
|
chmodSync(snapshot, 0o400);
|
|
expect(() => r.buildArgv(["session", "new"], snapshot))
|
|
.toThrow(/trusted runtime snapshot/i);
|
|
|
|
const symlink = join(snapshotRoot, "symlink.yaml");
|
|
symlinkSync(outside, symlink);
|
|
expect(() => r.buildArgv(["session", "new"], symlink))
|
|
.toThrow(/trusted runtime snapshot/i);
|
|
|
|
const directory = join(snapshotRoot, "directory.yaml");
|
|
mkdirSync(directory);
|
|
expect(() => r.buildArgv(["session", "new"], directory))
|
|
.toThrow(/trusted runtime snapshot/i);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("runtime snapshots require an absolute configured root", () => {
|
|
const relativeRoot = `tht-runner-relative-${Date.now()}`;
|
|
const r = new ThtRunner({
|
|
thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml", runtimeSnapshotRoot: relativeRoot,
|
|
});
|
|
try {
|
|
expect(() => r.createRuntimeSnapshot("language: en\n")).toThrow(/runtime snapshot root/i);
|
|
} finally {
|
|
rmSync(join(process.cwd(), relativeRoot), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("runtime snapshots are consumed through a read-only descriptor and cleaned after success", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "tht-runner-snapshot-"));
|
|
const snapshotRoot = join(root, "snapshots", "runtime");
|
|
const r = new ThtRunner({
|
|
thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml", runtimeSnapshotRoot: snapshotRoot,
|
|
});
|
|
try {
|
|
(spawn as any).mockClear();
|
|
await r.runWithRuntimeSnapshot(["session", "list", "--json"], "language: en\n");
|
|
const [, argv, options] = (spawn as any).mock.calls[0];
|
|
expect(argv.slice(-2)).toEqual(["-c", "/dev/fd/3"]);
|
|
expect(options.stdio).toHaveLength(4);
|
|
expect(readdirSync(snapshotRoot)).toEqual([]);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("runtime snapshots are cleaned after a failed child", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "tht-runner-snapshot-"));
|
|
const snapshotRoot = join(root, "snapshots", "runtime");
|
|
const r = new ThtRunner({
|
|
thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml", runtimeSnapshotRoot: snapshotRoot,
|
|
});
|
|
try {
|
|
(spawn as any).mockImplementationOnce(() => {
|
|
const ch: any = new EventEmitter();
|
|
ch.stdout = new EventEmitter();
|
|
ch.stderr = new EventEmitter();
|
|
queueMicrotask(() => ch.emit("close", 1));
|
|
return ch;
|
|
});
|
|
const result = await r.runWithRuntimeSnapshot(["session", "list", "--json"], "language: en\n");
|
|
expect(result.code).toBe(1);
|
|
expect(readdirSync(snapshotRoot)).toEqual([]);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("sqlPreview argv has no positional file — uses --session to resolve path", async () => {
|
|
// The harness preview_cmd now resolves sql_final.sql from the session workspace;
|
|
// the backend must NOT pass a sessions/<id>/sql_final.sql positional arg.
|
|
(spawn as any).mockClear();
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
|
// stub json() via run() — just need spawn call captured
|
|
r.run = async () => ({ code: 0, stdout: '{"columns":[],"rows":[],"execution_ms":1,"truncated":false}', stderr: "" });
|
|
await r.sqlPreview("ses1", { limit: 10, offset: 5 });
|
|
// Verify via the patched run — we stub run() so spawn isn't called again.
|
|
// Instead confirm directly that sqlPreview builds the right args by inspecting run calls.
|
|
// We swap back to a spy on run itself.
|
|
const runSpy = vi.fn().mockResolvedValue({
|
|
code: 0,
|
|
stdout: '{"columns":["c"],"rows":[[1]],"execution_ms":2,"truncated":false}',
|
|
stderr: "",
|
|
});
|
|
const r2 = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
|
r2.run = runSpy;
|
|
await r2.sqlPreview("ses2", { limit: 20, offset: 0 });
|
|
const [calledArgs] = runSpy.mock.calls[0];
|
|
// Must NOT include any positional file path before --session
|
|
expect(calledArgs).toEqual(["sql", "preview", "--session", "ses2", "--json", "--limit", "20", "--offset", "0"]);
|
|
expect(calledArgs).not.toContain("sessions/ses2/sql_final.sql");
|
|
});
|
|
|
|
test("setName builds the right argv", async () => {
|
|
const calls: string[][] = [];
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
r.run = async (args) => { calls.push(args); return { code: 0, stdout: "", stderr: "" }; };
|
|
await r.setName("sid", "Mio nome", "tenant-a");
|
|
expect(calls[0]).toEqual(["session", "set-name", "sid", "--name", "Mio nome"]);
|
|
});
|
|
|
|
test("mutation and document commands retain their requested workspace", async () => {
|
|
const calls: Array<{ args: string[]; workspace?: string }> = [];
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
r.run = async (args, workspace) => { calls.push({ args, workspace }); return { code: 0, stdout: "[]", stderr: "" }; };
|
|
await r.setGroup("sid", "G1", "tenant-a");
|
|
await r.archive("sid", "tenant-a");
|
|
await r.unarchive("sid", "tenant-a");
|
|
await r.documents("sid", "tenant-a");
|
|
await r.deleteSession("sid", "tenant-a");
|
|
expect(calls).toEqual([
|
|
{ args: ["session", "set-group", "sid", "--group", "G1"], workspace: "tenant-a" },
|
|
{ args: ["session", "archive", "sid"], workspace: "tenant-a" },
|
|
{ args: ["session", "unarchive", "sid"], workspace: "tenant-a" },
|
|
{ args: ["session", "documents", "sid", "--json"], workspace: "tenant-a" },
|
|
{ args: ["session", "delete", "sid"], workspace: "tenant-a" },
|
|
]);
|
|
});
|
|
|
|
test("ok() throws on non-zero exit", async () => {
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: nope" });
|
|
await expect(r.archive("sid")).rejects.toThrow(/nope/);
|
|
});
|
|
|
|
test("documents parses the JSON array", async () => {
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
|
r.run = async () => ({
|
|
code: 0,
|
|
stdout: '[{"phase":"—","key":"question","title":"Domanda originale","format":"text","content":"q"}]',
|
|
stderr: "",
|
|
});
|
|
const docs = await r.documents("sid");
|
|
expect(docs[0].key).toBe("question");
|
|
});
|
|
|
|
test("ollamaEnsure builds argv with --json --timeout and the workspace -c", async () => {
|
|
let calledArgs: string[] = [];
|
|
let calledWs: string | undefined;
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
|
r.run = async (args, ws) => { calledArgs = args; calledWs = ws; return { code: 0, stdout: '{"ok":true,"server":"up","model":"warmed","model_name":"m"}', stderr: "" }; };
|
|
const res = await r.ollamaEnsure("psd", 60);
|
|
expect(calledArgs).toEqual(["ollama", "ensure", "--json", "--timeout", "60"]);
|
|
expect(calledWs).toBe("psd");
|
|
expect(res).toEqual({ ok: true, server: "up", model: "warmed", model_name: "m" });
|
|
});
|
|
|
|
test("ollamaEnsure maps a non-zero exit to ok:false with stage/error from stdout JSON", async () => {
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
|
r.run = async () => ({ code: 1, stdout: '{"ok":false,"stage":"model","error":"missing"}', stderr: "" });
|
|
expect(await r.ollamaEnsure("psd", 60)).toEqual({ ok: false, stage: "model", error: "missing" });
|
|
});
|
|
|
|
test("ollamaEnsure falls back to stderr when stdout is not JSON on failure", async () => {
|
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
|
r.run = async () => ({ code: 1, stdout: "", stderr: "boom" });
|
|
const res = await r.ollamaEnsure("psd", 60);
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error).toContain("boom");
|
|
});
|