import { test, expect } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; const userHeaders = { "x-thoth-principal-issuer": "portal", "x-thoth-principal-subject": "alice", "x-thoth-is-admin": "0", }; const adminHeaders = { ...userHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" }; function appWithTmpSettings(extraEnv: Record = {}, deps = {}) { const dir = mkdtempSync(join(tmpdir(), "tht-set-route-")); const app = buildApp( loadConfig({ THT_HARNESS_DIR: "../harness", SETTINGS_FILE: join(dir, "settings.json"), ...extraEnv }), { thtRunner: {} as any, ...deps }, ); return { app, dir }; } test("GET /settings returns effective defaults (env provider/model/thinking, first workspace)", async () => { const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, { listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], }); try { const res = await app.inject({ method: "GET", url: "/settings" }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.provider).toBe("zai"); expect(body.model).toBe("glm-5.2"); expect(body.thinking).toBe("medium"); expect(typeof body.workspace).toBe("string"); // first workspace from ../harness/workspaces } finally { rmSync(dir, { recursive: true, force: true }); } }); test("GET /settings uses the stored installation workspace default before the harness fallback", async () => { const { app, dir } = appWithTmpSettings({}); try { writeFileSync(join(dir, "settings.json"), JSON.stringify({ workspace: "psd-clinical" })); const response = await app.inject({ method: "GET", url: "/settings" }); expect(response.json()).toMatchObject({ workspace: "psd-clinical" }); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("PUT /settings does not persist personal workspace or LLM choices", async () => { const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, { listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], }); try { const put = await app.inject({ method: "PUT", url: "/settings", payload: { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }, }); expect(put.statusCode).toBe(200); const got = await app.inject({ method: "GET", url: "/settings" }); expect(got.json()).toMatchObject({ provider: "zai", model: "glm-5.2", thinking: "medium" }); expect(got.json()).not.toMatchObject({ workspace: "psd", thinking: "high" }); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("PUT /settings requires settings.manage", async () => { const { app, dir } = appWithTmpSettings({ AUTH_MODE: "upstream" }, { listModels: async () => [] }); try { const body = { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }; const denied = await app.inject({ method: "PUT", url: "/settings", headers: userHeaders, payload: body }); const allowed = await app.inject({ method: "PUT", url: "/settings", headers: adminHeaders, payload: body }); expect(denied.statusCode).toBe(403); expect(denied.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" }); expect(allowed.statusCode).toBe(200); } finally { await app.close(); rmSync(dir, { recursive: true, force: true }); } }); test("settings no longer read or write principal-specific preferences", async () => { const preferences = new Map(); const runner = { withPrincipal: (principal: any) => ({ preferencesGet: async () => preferences.get(principal.subject) ?? {}, preferencesSet: async (next: any) => { preferences.set(principal.subject, next); }, }), }; const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), { thtRunner: runner as any, listModels: async () => [], }); const headers = (subject: string) => ({ "x-thoth-principal-issuer": "portal", "x-thoth-principal-subject": subject, "x-thoth-is-admin": "0", }); await app.inject({ method: "PUT", url: "/settings", headers: headers("alice"), payload: { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }, }); const alice = await app.inject({ method: "GET", url: "/settings", headers: headers("alice") }); const bob = await app.inject({ method: "GET", url: "/settings", headers: headers("bob") }); expect(alice.json()).toEqual(bob.json()); expect(preferences.size).toBe(0); }); test("GET /settings retains complete legacy installation defaults without seeding a private profile", async () => { let preferences: Record = {}; const writes: Record[] = []; const runner = { withPrincipal: () => ({ preferencesGet: async () => preferences, preferencesSet: async (next: Record) => { writes.push(next); preferences = next; }, }), }; const { app, dir } = appWithTmpSettings({}, { thtRunner: runner as any, listModels: async () => [] }); try { writeFileSync(join(dir, "settings.json"), JSON.stringify({ workspace: "local", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", })); const first = await app.inject({ method: "GET", url: "/settings" }); const second = await app.inject({ method: "GET", url: "/settings" }); const expected = { workspace: "local", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", }; expect(first.statusCode).toBe(200); expect(first.json()).toEqual(expected); expect(second.json()).toEqual(expected); expect(writes).toEqual([]); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("GET /settings ignores stale private preferences in favor of installation defaults", async () => { const privateSettings = { workspace: "private", provider: "zai", model: "glm-5.2", thinking: "high", }; const preferencesSet = async () => { throw new Error("must not seed an existing profile"); }; const runner = { withPrincipal: () => ({ preferencesGet: async () => privateSettings, preferencesSet, }), }; const { app, dir } = appWithTmpSettings({}, { thtRunner: runner as any, listModels: async () => [] }); try { writeFileSync(join(dir, "settings.json"), JSON.stringify({ workspace: "local", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", })); const response = await app.inject({ method: "GET", url: "/settings" }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ workspace: "local", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", }); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("PUT /settings rejects an unknown model when a model list is available", async () => { const { app, dir } = appWithTmpSettings({}, { listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], }); try { const put = await app.inject({ method: "PUT", url: "/settings", payload: { workspace: "psd", provider: "zai", model: "does-not-exist", thinking: "low" }, }); expect(put.statusCode).toBe(400); expect(put.json()).toMatchObject({ error: expect.stringMatching(/model/i) }); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("PUT /settings validates provider and model as one composite identifier", async () => { const { app, dir } = appWithTmpSettings({}, { listModels: async () => [ { provider: "provider-a", id: "shared-id", name: "A", reasoning: false }, ], }); try { const wrongProvider = await app.inject({ method: "PUT", url: "/settings", payload: { workspace: "psd", provider: "provider-b", model: "shared-id", thinking: "low", }, }); expect(wrongProvider.statusCode).toBe(400); const exactPair = await app.inject({ method: "PUT", url: "/settings", payload: { workspace: "psd", provider: "provider-a", model: "shared-id", thinking: "low", }, }); expect(exactPair.statusCode).toBe(200); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("PUT /settings allows any model when model list is empty (Pi unavailable)", async () => { const { app, dir } = appWithTmpSettings({}, { listModels: async () => [] }); try { const put = await app.inject({ method: "PUT", url: "/settings", payload: { workspace: "psd", model: "whatever", thinking: "low" }, }); expect(put.statusCode).toBe(200); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("CORS preflight allows PUT /settings (browser can save settings)", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, listModels: async () => [], }); const res = await app.inject({ method: "OPTIONS", url: "/settings", headers: { origin: "http://localhost:5173", "access-control-request-method": "PUT", }, }); // @fastify/cors answers the preflight; PUT must be in the allowed methods. expect(res.headers["access-control-allow-methods"]).toMatch(/PUT/); });