import Fastify, { type FastifyInstance } from "fastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; let app: FastifyInstance; let savedConfig: PiWebConfigValues; let service: PiWebConfigService; beforeEach(async () => { savedConfig = { host: "127.0.0.1", port: 8504, allowedHosts: [] }; service = { read: vi.fn(() => responseFor(savedConfig, true)), write: vi.fn((config: PiWebConfigValues) => { savedConfig = config; return responseFor(savedConfig, true); }), }; app = Fastify({ logger: false }); registerConfigRoutes(app, service); registerLocalMachineConfigRoutes(app, service); await app.ready(); }); afterEach(async () => { await app.close(); }); describe("config routes", () => { it("returns the PI WEB config contract", async () => { const response = await app.inject({ method: "GET", url: "/api/config" }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual(responseFor(savedConfig, true)); }); it("updates config through the service", async () => { const requestedConfig: PiWebConfigValues = { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234, }; const expectedConfig: PiWebConfigValues = { ...requestedConfig, uploads: { defaultFolder: "uploads/manual" }, }; const response = await app.inject({ method: "PUT", url: "/api/config", payload: { config: requestedConfig }, }); expect(response.statusCode).toBe(200); expect(savedConfig).toEqual(expectedConfig); expect(response.json().config).toEqual(expectedConfig); }); it("rejects invalid config payloads before writing", async () => { const response = await app.inject({ method: "PUT", url: "/api/config", payload: { config: { host: 42 } }, }); expect(response.statusCode).toBe(400); expect(response.json()).toHaveProperty("error"); expect(service.write).not.toHaveBeenCalled(); }); it("rejects invalid path access payloads before writing", async () => { const response = await app.inject({ method: "PUT", url: "/api/config", payload: { config: { pathAccess: { allowedPaths: [""] } } }, }); expect(response.statusCode).toBe(400); expect(response.json()).toHaveProperty("error"); expect(service.write).not.toHaveBeenCalled(); }); it("rejects invalid max upload bytes before writing", async () => { const response = await app.inject({ method: "PUT", url: "/api/config", payload: { config: { maxUploadBytes: 0 } }, }); expect(response.statusCode).toBe(400); expect(response.json()).toHaveProperty("error"); expect(service.write).not.toHaveBeenCalled(); }); it("rejects invalid upload defaults before writing", async () => { const response = await app.inject({ method: "PUT", url: "/api/config", payload: { config: { uploads: { defaultFolder: "/tmp" } } }, }); expect(response.statusCode).toBe(400); expect(response.json()).toHaveProperty("error"); expect(service.write).not.toHaveBeenCalled(); }); it("filters local machine config reads to selected-machine-safe keys", async () => { savedConfig = fullConfig(); const response = await app.inject({ method: "GET", url: "/api/machines/local/config" }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ ...responseFor(savedConfig, true), config: selectedMachineConfig(), effectiveConfig: selectedMachineConfig(), }); }); it("merges local selected-machine config updates without dropping gateway-only keys", async () => { savedConfig = fullConfig(); const selectedMachinePatch: PiWebConfigValues = { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true, }; const response = await app.inject({ method: "PUT", url: "/api/machines/local/config", payload: { config: selectedMachinePatch }, }); const expectedConfig: PiWebConfigValues = { ...fullConfig(), plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads/manual" }, spawnSessions: true, }; expect(response.statusCode).toBe(200); expect(savedConfig).toEqual(expectedConfig); expect(service.write).toHaveBeenCalledWith(expectedConfig); expect(response.json().config).toEqual({ plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/repos"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1024, spawnSessions: true, subsessions: false, }); }); it("rejects unsafe local selected-machine config keys before writing", async () => { savedConfig = fullConfig(); const response = await app.inject({ method: "PUT", url: "/api/machines/local/config", payload: { config: { host: "0.0.0.0", spawnSessions: true } }, }); expect(response.statusCode).toBe(400); expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host"); expect(savedConfig).toEqual(fullConfig()); expect(service.write).not.toHaveBeenCalled(); }); it("rejects invalid local selected-machine config values before writing", async () => { const response = await app.inject({ method: "PUT", url: "/api/machines/local/config", payload: { config: { spawnSessions: "yes" } }, }); expect(response.statusCode).toBe(400); expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config spawnSessions must be a boolean"); expect(service.write).not.toHaveBeenCalled(); }); }); function fullConfig(): PiWebConfigValues { return { host: "127.0.0.1", port: 8504, allowedHosts: ["gateway.example.test"], shortcuts: { "core:view.chat": "mod+1" }, plugins: { info: { enabled: true, settings: { note: "visible" } } }, pathAccess: { allowedPaths: ["/srv/repos"] }, uploads: { defaultFolder: "uploads" }, maxUploadBytes: 1024, spawnSessions: false, subsessions: false, }; } function selectedMachineConfig(): PiWebConfigValues { return { plugins: { info: { enabled: true, settings: { note: "visible" } } }, pathAccess: { allowedPaths: ["/srv/repos"] }, uploads: { defaultFolder: "uploads" }, maxUploadBytes: 1024, spawnSessions: false, subsessions: false, }; } function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse { return { path: "/tmp/pi-web/config.json", exists, config, effectiveConfig: config, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, }; }