import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "events"; import fs from "fs/promises"; import os from "os"; import path from "path"; import express, { type Request, type RequestHandler, type Response, } from "express"; const state = vi.hoisted(() => ({ userId: "admin-1", settings: {} as Record, sessions: [] as unknown[], })); vi.mock("../../../utils/logger.js", () => ({ authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, })); vi.mock("../../../hosts/terminal/session-manager.js", () => ({ sessionManager: { getUserSessions: () => state.sessions, }, })); vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async (key: string) => state.settings[key] ?? null, set: async (key: string, value: string) => { state.settings[key] = value; }, setMany: async (writes: Array<{ key: string; value: string }>) => { for (const write of writes) state.settings[write.key] = write.value; }, }), })); const { registerUserImageStorageRoutes } = await import("../../../database/routes/user-image-storage-routes.js"); const requireAdmin: RequestHandler = (_req, _res, next) => next(); const router = express.Router(); registerUserImageStorageRoutes(router, requireAdmin); interface RouteLayer { route?: { path: string; methods: Record; stack: Array<{ handle: RequestHandler }>; }; } function handlerFor(pathName: string, method: string): RequestHandler { const layer = (router as unknown as { stack: RouteLayer[] }).stack.find( (candidate) => candidate.route?.path === pathName && candidate.route.methods[method], ); if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`); return layer.route!.stack[layer.route!.stack.length - 1]!.handle; } const getHandler = handlerFor("/terminal-image-storage-settings", "get"); const patchHandler = handlerFor("/terminal-image-storage-settings", "patch"); const testHandler = handlerFor("/terminal-image-storage-settings/test", "post"); async function invoke(handler: RequestHandler, body?: unknown) { const req = { userId: state.userId, body: body ?? {}, headers: {}, } as unknown as Request; const result = { statusCode: 200, body: null as unknown }; const res = { status(code: number) { result.statusCode = code; return this; }, json(payload: unknown) { result.body = payload; return this; }, } as unknown as Response; await handler(req, res, () => {}); return result; } /** Fake ssh2 exec channel: every command exits 0. */ function fakeSshConn() { return { exec( _command: string, callback: (error: Error | undefined, stream?: unknown) => void, ) { const stream = new EventEmitter() as EventEmitter & { resume: () => void; }; stream.resume = () => queueMicrotask(() => stream.emit("close", 0)); callback(undefined, stream); }, }; } const LEGACY_ENV_NAMES = [ "TERMIX_IMAGE_STORAGE_MODE", "TERMIX_IMAGE_DIR", "TERMIX_IMAGE_HOST_PATH", "TERMIX_IMAGE_TTL_MS", "TERMIX_MAX_IMAGE_COUNT", "TERMIX_MAX_IMAGE_STORAGE_BYTES", "DATA_DIR", ]; let savedEnv: Record; let localDir: string; beforeEach(async () => { state.settings = {}; state.sessions = []; savedEnv = Object.fromEntries( LEGACY_ENV_NAMES.map((name) => [name, process.env[name]]), ); for (const name of LEGACY_ENV_NAMES) delete process.env[name]; // The settings validator rejects backslashes, so use a POSIX-style form of // the real temp dir. Windows still resolves it, so file checks keep working. localDir = ( await fs.mkdtemp(path.join(os.tmpdir(), "termix-image-test-")) ).replace(/\\/g, "/"); }); afterEach(async () => { for (const name of LEGACY_ENV_NAMES) { if (savedEnv[name] === undefined) delete process.env[name]; else process.env[name] = savedEnv[name]; } await fs.rm(localDir, { recursive: true, force: true }); }); describe("GET /users/terminal-image-storage-settings", () => { it("returns the public settings shape without the backend localDir", async () => { state.settings["terminal_image_storage_mode"] = "local"; state.settings["terminal_image_local_dir"] = localDir; state.settings["terminal_image_host_path"] = "/mnt/images"; const response = await invoke(getHandler); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ mode: "local", hostPath: "/mnt/images", ttlMs: 3_600_000, maxCount: 100, maxBytes: 5_368_709_120, localMappingConfigured: true, }); expect(JSON.stringify(response.body)).not.toContain(localDir); }); }); describe("PATCH /users/terminal-image-storage-settings", () => { it("rejects an invalid mode with a safe 400", async () => { const response = await invoke(patchHandler, { mode: "nfs" }); expect(response.statusCode).toBe(400); expect(response.body).toEqual({ error: "Invalid value for mode", code: "IMAGE_STORAGE_SETTINGS_INVALID", field: "mode", }); }); it("rejects a relative localDir", async () => { const response = await invoke(patchHandler, { localDir: "images/tmp" }); expect(response.statusCode).toBe(400); expect(response.body).toMatchObject({ code: "IMAGE_STORAGE_SETTINGS_INVALID", field: "localDir", }); expect(JSON.stringify(response.body)).not.toContain("images/tmp"); }); it("rejects out-of-range numeric limits", async () => { const response = await invoke(patchHandler, { maxBytes: 1024 }); expect(response.statusCode).toBe(400); expect(response.body).toMatchObject({ code: "IMAGE_STORAGE_SETTINGS_INVALID", field: "maxBytes", }); }); it("rejects unknown fields without writing anything", async () => { const response = await invoke(patchHandler, { shellPath: "/tmp/x" }); expect(response.statusCode).toBe(400); expect(response.body).toMatchObject({ code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD", }); expect(state.settings).toEqual({}); }); // On Windows path.resolve turns the stored dir back into a backslash path, // which the validator rejects on read, so the round trip only holds on POSIX. it.skipIf(process.platform === "win32")( "persists a valid partial update and returns the public shape", async () => { const response = await invoke(patchHandler, { mode: "local", localDir, hostPath: "/host/images", ttlMs: 60_000, maxCount: 5, maxBytes: 10_485_760, }); expect(response.statusCode).toBe(200); expect(state.settings).toEqual({ terminal_image_storage_mode: "local", terminal_image_local_dir: path.resolve(localDir), terminal_image_host_path: "/host/images", terminal_image_ttl_ms: "60000", terminal_image_max_count: "5", terminal_image_max_storage_bytes: "10485760", }); expect(response.body).toMatchObject({ mode: "local", hostPath: "/host/images", ttlMs: 60_000, maxCount: 5, maxBytes: 10_485_760, localMappingConfigured: true, }); expect(JSON.stringify(response.body)).not.toContain(localDir); }, ); }); describe("POST /users/terminal-image-storage-settings/test", () => { it("requires an instanceId", async () => { const response = await invoke(testHandler, {}); expect(response.statusCode).toBe(400); expect(response.body).toMatchObject({ code: "IMAGE_SESSION_MISSING" }); }); it("reports unavailable storage when no session is connected", async () => { const response = await invoke(testHandler, { instanceId: "tab-1" }); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ mode: "auto", connected: false, remoteSftpAvailable: false, localHostVisible: null, selectedMode: "unavailable", localMappingConfigured: false, }); }); it("probes local visibility through the connected session only", async () => { state.settings["terminal_image_local_dir"] = localDir; state.settings["terminal_image_host_path"] = "/host/images"; state.sessions = [ { tabInstanceId: "tab-1", isConnected: true, sshConn: fakeSshConn(), }, ]; const response = await invoke(testHandler, { instanceId: "tab-1" }); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ mode: "auto", connected: true, remoteSftpAvailable: true, localHostVisible: true, selectedMode: "local", localMappingConfigured: true, }); // The bounded probe cleans up after itself. expect( (await fs.readdir(localDir)).filter((f) => f.includes("probe")), ).toEqual([]); }); it("does not probe sessions owned by other instance IDs", async () => { state.sessions = [ { tabInstanceId: "tab-2", isConnected: true, sshConn: fakeSshConn(), }, ]; const response = await invoke(testHandler, { instanceId: "tab-1" }); expect(response.statusCode).toBe(200); expect(response.body).toMatchObject({ connected: false, remoteSftpAvailable: false, localHostVisible: null, selectedMode: "unavailable", }); }); });