feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

@@ -1,304 +0,0 @@
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<string, string>,
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<string, boolean>;
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<string, string | undefined>;
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",
});
});
});
@@ -1,192 +0,0 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMAGE_HOST_PATH,
DEFAULT_IMAGE_MAX_BYTES,
DEFAULT_IMAGE_MAX_COUNT,
DEFAULT_IMAGE_TTL_MS,
defaultImageLocalDir,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
TERMINAL_IMAGE_STORAGE_KEYS,
} from "../../../database/routes/terminal-image-storage-settings.js";
import { SettingsRepository } from "../../../database/repositories/settings-repository.js";
import { TestSqliteDatabase } from "../repositories/test-support.js";
// parseImageLocalDir resolves against the host platform, so a POSIX literal
// becomes a drive-rooted path on Windows. Compare against the same resolution.
function localDir(posixPath: string): string {
return path.resolve(posixPath);
}
function stubSettings(values: Record<string, string> = {}) {
return {
get: async (key: string) => values[key] ?? null,
};
}
const EMPTY_ENV: NodeJS.ProcessEnv = {};
describe("terminal image storage settings", () => {
describe("mode parsing", () => {
it("accepts the three documented modes case-insensitively", () => {
expect(parseTerminalImageStorageMode("auto")).toBe("auto");
expect(parseTerminalImageStorageMode("LOCAL")).toBe("local");
expect(parseTerminalImageStorageMode(" remote-sftp ")).toBe(
"remote-sftp",
);
});
it("rejects unknown modes and non-strings", () => {
expect(parseTerminalImageStorageMode("s3")).toBeNull();
expect(parseTerminalImageStorageMode("")).toBeNull();
expect(parseTerminalImageStorageMode(undefined)).toBeNull();
});
});
describe("path validation", () => {
it("accepts absolute local directories and normalizes them", () => {
expect(parseImageLocalDir("/var/lib/termix/images")).toBe(
localDir("/var/lib/termix/images"),
);
expect(parseImageLocalDir("/var/lib/termix/../termix/images")).toBeNull();
});
it("rejects relative, empty and NUL-containing local directories", () => {
expect(parseImageLocalDir("images")).toBeNull();
expect(parseImageLocalDir("./db/data/images")).toBeNull();
expect(parseImageLocalDir("")).toBeNull();
expect(parseImageLocalDir("/tmp/a\0b")).toBeNull();
});
it("requires the agent-visible host path to be POSIX-absolute", () => {
expect(parseImageHostPath("/tmp/termix-image-v0")).toBe(
"/tmp/termix-image-v0",
);
expect(parseImageHostPath("tmp/termix-image-v0")).toBeNull();
expect(parseImageHostPath("C:\\images")).toBeNull();
expect(parseImageHostPath("/tmp/a\0b")).toBeNull();
});
});
describe("resolution precedence", () => {
it("uses built-in defaults when neither the database nor env has values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
EMPTY_ENV,
);
expect(resolved.mode).toBe("auto");
expect(resolved.localDir).toBe(defaultImageLocalDir(EMPTY_ENV));
expect(resolved.hostPath).toBe(DEFAULT_IMAGE_HOST_PATH);
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
expect(resolved.maxBytes).toBe(DEFAULT_IMAGE_MAX_BYTES);
});
it("seeds defaults from legacy TERMIX_IMAGE_* env when no DB value exists", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_HOST_PATH: "/tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10485760",
},
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.hostPath).toBe("/tmp/images");
expect(resolved.ttlMs).toBe(60_000);
expect(resolved.maxCount).toBe(5);
expect(resolved.maxBytes).toBe(10_485_760);
});
it("lets persisted DB values win over legacy env values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "/db/images",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "1000",
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: "7",
}),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
},
);
expect(resolved.localDir).toBe(localDir("/db/images"));
expect(resolved.ttlMs).toBe(1_000);
expect(resolved.maxCount).toBe(7);
});
it("falls through to env and defaults when the DB value is invalid", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "relative/path",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "not-a-number",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
});
it("keeps legacy explicit local mappings on local mode", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("local");
// hostPath falls back to the legacy local dir, which resolves natively.
expect(resolved.hostPath).toBe(localDir("/host-tmp/images"));
expect(resolved.localMappingConfigured).toBe(true);
});
it("lets a persisted DB mode override the legacy local mapping", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: "remote-sftp",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("remote-sftp");
});
it("clamps out-of-range numeric values like the legacy env parsing did", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_TTL_MS: "-5",
TERMIX_MAX_IMAGE_COUNT: "0",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10",
},
);
expect(resolved.ttlMs).toBe(0);
expect(resolved.maxCount).toBe(1);
expect(resolved.maxBytes).toBe(1_048_576);
});
it("reads persisted values through the real settings repository", async () => {
const adapter = new TestSqliteDatabase();
try {
const context = await adapter.connect();
const repository = new SettingsRepository(context);
await repository.set(TERMINAL_IMAGE_STORAGE_KEYS.mode, "local");
await repository.set(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
"/persisted/images",
);
const resolved = await resolveTerminalImageStorageSettings(repository, {
TERMIX_IMAGE_DIR: "/host-tmp/images",
});
expect(resolved.mode).toBe("local");
expect(resolved.localDir).toBe(localDir("/persisted/images"));
} finally {
await adapter.close();
}
});
});
});
@@ -1,571 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EventEmitter } from "events";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { randomUUID } from "crypto";
import {
REMOTE_IMAGE_DIR,
selectImageStorageMode,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "../../../database/routes/terminal-image-storage.js";
import type { TerminalImageStorageSettings } from "../../../database/routes/terminal-image-storage-settings.js";
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
function settings(
overrides: Partial<TerminalImageStorageSettings>,
): TerminalImageStorageSettings {
return {
mode: "local",
localDir: "/nonexistent",
hostPath: "/tmp/termix-image-v0",
ttlMs: 3_600_000,
maxCount: 100,
maxBytes: 5_368_709_120,
localMappingConfigured: false,
...overrides,
};
}
describe("selectImageStorageMode", () => {
it("keeps explicit modes deterministic regardless of capability", () => {
expect(
selectImageStorageMode(settings({ mode: "local" }), {
remoteSftpAvailable: true,
}),
).toBe("local");
expect(
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
remoteSftpAvailable: false,
}),
).toBe("remote-sftp");
});
it("falls back on capability only in auto mode", () => {
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: true,
}),
).toBe("remote-sftp");
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: false,
}),
).toBe("unavailable");
expect(
selectImageStorageMode(
settings({ mode: "auto", localMappingConfigured: true }),
{ remoteSftpAvailable: false, localHostVisible: true },
),
).toBe("local");
});
});
describe("storeImageLocally", () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
const name = `${randomUUID()}.png`;
const filePath = path.join(dir, name);
await fs.writeFile(filePath, Buffer.alloc(bytes));
if (mtimeMs !== undefined) {
const date = new Date(mtimeMs);
await fs.utimes(filePath, date, date);
}
return name;
}
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, hostPath: "/host-view/images" }),
);
expect(stored.storage).toBe("local");
expect(stored.filename).toBe(`${stored.id}.png`);
expect(stored.shellPath).toBe(
path.posix.join("/host-view/images", stored.filename),
);
expect(stored.shellPath).not.toContain(dir);
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
PNG_BYTES,
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
await seedFile(10);
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1 }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
await seedFile(900);
const error = await storeImageLocally(
Buffer.alloc(200),
settings({ localDir: dir, maxBytes: 1_000 }),
).catch((caught: unknown) => caught);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("cleans expired files during upload so they no longer count", async () => {
await seedFile(10, Date.now() - 2 * 3_600_000);
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
);
expect(stored.storage).toBe("local");
const remaining = await fs.readdir(dir);
expect(remaining).toEqual([stored.filename]);
});
it("fails closed when local storage inspection is unavailable", async () => {
const blocked = path.join(dir, "blocked-file");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: blocked }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
it("reports inspection failures before attempting a write", async () => {
const blocked = path.join(dir, "blocked");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: path.join(blocked, "images") }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
});
describe("storeImageViaSftp", () => {
function fakeSftp(behavior: {
mkdirError?: Error;
writeError?: Error;
stallWrite?: boolean;
stallLock?: boolean;
stallReaddir?: boolean;
stallRmdir?: boolean;
stallUnlink?: boolean;
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
readdirError?: Error;
statMode?: number;
}): {
sftp: ImageSftpClient;
written: Map<string, Buffer>;
calls: {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
>;
} {
const written = new Map<string, Buffer>();
const streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
> = [];
const calls = { mkdir: [], createWriteStream: [] } as {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
const sftp = {
mkdir: (
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
maybeCallback?: (err?: Error) => void,
) => {
const callback =
typeof attrsOrCallback === "function"
? attrsOrCallback
: maybeCallback!;
calls.mkdir.push({
dir,
mode:
typeof attrsOrCallback === "function"
? undefined
: attrsOrCallback.mode,
});
if (
behavior.stallLock &&
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
) {
return;
}
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
},
stat: (
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
callback(),
readdir: (
_dir: string,
callback: (
error: Error | undefined,
entries: Array<{
filename: string;
attrs?: { mtime?: number; size?: number };
}>,
) => void,
) => {
if (behavior.stallReaddir) return;
callback(
behavior.readdirError,
behavior.readdirEntries?.map((entry) => ({
filename: entry.filename,
attrs: entry,
})) ?? [],
);
},
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
const stream = new EventEmitter() as NodeJS.WritableStream & {
end: (data: Buffer) => void;
destroy: () => void;
destroyed: boolean;
};
stream.destroyed = false;
stream.destroy = () => {
stream.destroyed = true;
};
streams.push(stream);
stream.end = (data: Buffer) => {
if (behavior.stallWrite) return;
queueMicrotask(() => {
if (behavior.writeError) {
stream.emit("error", behavior.writeError);
return;
}
written.set(remotePath, data);
stream.emit("close");
});
};
return stream;
},
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
if (behavior.stallUnlink) return;
callback();
},
rmdir: (_dir: string, callback: (error?: Error) => void) => {
if (behavior.stallRmdir) return;
callback();
},
} as unknown as ImageSftpClient;
return { sftp, written, calls, streams };
}
it("writes into the remote image directory and returns its POSIX path", async () => {
const { sftp, written } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
});
it("requests restrictive modes for the remote directory and file", async () => {
const { sftp, calls } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(calls.mkdir).toEqual([
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
]);
expect(calls.createWriteStream).toEqual([
{ path: stored.shellPath, mode: 0o600 },
]);
});
it("removes expired UUID PNGs with best-effort remote retention", async () => {
const nowMs = 10_000_000;
const expiredName = `${randomUUID()}.png`;
const freshName = `${randomUUID()}.png`;
const unlinked: string[] = [];
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient & {
readdir: (
dir: string,
callback: (
err: Error | undefined,
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
) => void,
) => void;
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
};
sftp.readdir = (_dir, callback) =>
callback(undefined, [
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
{ filename: "other.txt", attrs: { mtime: 0 } },
]);
sftp.unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 3_600_000,
nowMs,
});
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
});
it("fails closed when remote quota inspection fails", async () => {
const { sftp } = fakeSftp({
readdirError: new Error("remote listing failed"),
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
);
});
it("rejects remote writes at the configured count limit", async () => {
const { sftp } = fakeSftp({
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 1,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("destroys a stalled SFTP write after its timeout", async () => {
const { sftp, streams } = fakeSftp({ stallWrite: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
writeTimeoutMs: 25,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect(streams[0]!.destroyed).toBe(true);
});
it("rejects an existing remote path that is not a directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
statMode: 0o100644,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("preserves SFTP client context for directory inspection", async () => {
const base = fakeSftp({ mkdirError: new Error("already exists") });
const sftp = base.sftp;
sftp.lstat = function (
this: ImageSftpClient,
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) {
if (this !== sftp) {
callback(new Error("SFTP context lost"));
return;
}
callback(undefined, { mode: 0o40700 });
};
const result = await storeImageViaSftp(sftp, PNG_BYTES);
expect(result.storage).toBe("remote-sftp");
});
it("tolerates mkdir failures for an already-existing directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
});
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
});
it("cleans up a partially created remote file after a write failure", async () => {
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
const unlinked: string[] = [];
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect(unlinked).toHaveLength(1);
expect(unlinked[0]).toMatch(
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
);
});
it("preserves the write error when partial-file cleanup stalls", async () => {
const { sftp } = fakeSftp({
writeError: new Error("write failed"),
stallUnlink: true,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("recovers a stale remote lock before writing", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
let lockAttempt = 0;
const removed: string[] = [];
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => {
if (dir === lockPath && lockAttempt++ === 0) {
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
return;
}
originalMkdir(dir, attrs, callback);
};
sftp.lstat = (_dir, callback) =>
callback(undefined, {
mode: 0o40700,
mtime: (Date.now() - 60_000) / 1000,
});
sftp.rmdir = (dir, callback) => {
removed.push(dir);
callback();
};
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
expect(removed).toContain(lockPath);
});
it("does not replace a successful write with an unlock failure", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
sftp.rmdir = (dir, callback) => {
if (dir === lockPath) callback(new Error("unlock failed"));
else callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("fails closed when remote lock acquisition stalls", async () => {
const { sftp } = fakeSftp({ stallLock: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 12_000);
it("fails closed when remote quota inspection stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote expiry cleanup stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 1_000,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote lock release stalls", async () => {
const { sftp } = fakeSftp({ stallRmdir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect((error as TerminalImageStorageError).message).toBe(
"Failed to write image to the remote host",
);
});
});
@@ -1,541 +0,0 @@
import {
afterEach,
beforeAll,
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 { randomUUID } from "crypto";
import sharp from "sharp";
import type { Request, RequestHandler, Response } from "express";
import type { ImageSftpClient } from "../../../database/routes/terminal-image-storage.js";
import { databaseLogger } from "../../../utils/logger.js";
const state = vi.hoisted(() => ({
userId: "user-1",
settings: {} as Record<string, string>,
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("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
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,
}),
createCurrentHostResolutionRepository: () => ({}),
createCurrentCommandHistoryRepository: () => ({}),
}));
const { default: router } =
await import("../../../database/routes/terminal.js");
interface RouteLayer {
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: RequestHandler }>;
};
}
const imageUploadLayer = (
router as unknown as { stack: RouteLayer[] }
).stack.find(
(layer) => layer.route?.path === "/image-upload" && layer.route.methods.post,
);
const imageUploadHandler =
imageUploadLayer!.route!.stack[imageUploadLayer!.route!.stack.length - 1]!
.handle;
function fakeSftp(behavior: { writeError?: Error } = {}): {
sftp: ImageSftpClient;
written: Map<string, Buffer>;
end: ReturnType<typeof vi.fn>;
} {
const written = new Map<string, Buffer>();
const end = vi.fn();
const sftp = {
mkdir: (
_dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
maybeCallback?: (err?: Error) => void,
) => {
const callback =
typeof attrsOrCallback === "function"
? attrsOrCallback
: maybeCallback!;
callback();
},
createWriteStream: (remotePath: string, _options?: { mode?: number }) => {
const stream = new EventEmitter() as NodeJS.WritableStream & {
end: (data: Buffer) => void;
};
stream.end = (data: Buffer) => {
queueMicrotask(() => {
if (behavior.writeError) {
stream.emit("error", behavior.writeError);
return;
}
written.set(remotePath, data);
stream.emit("close");
});
};
return stream;
},
readdir: (
_dir: string,
callback: (
error: Error | undefined,
entries: Array<{
filename: string;
attrs?: { size?: number; mtime?: number };
}>,
) => void,
) => callback(undefined, []),
unlink: (_path: string, callback: (error?: Error) => void) => callback(),
rmdir: (_dir: string, callback: (error?: Error) => void) => callback(),
end,
} as unknown as ImageSftpClient & { end: ReturnType<typeof vi.fn> };
return { sftp, written, end };
}
function connectedSession(instanceId: string, sftp: ImageSftpClient) {
return {
tabInstanceId: instanceId,
isConnected: true,
sshConn: {
sftp: (
callback: (err: Error | undefined, sftp: ImageSftpClient) => void,
) => callback(undefined, sftp),
},
};
}
async function invoke(options: {
file?: { buffer: Buffer; mimetype: string; size: number };
instanceId?: string;
metadata?: { source?: string; clientUploadTimestamp?: string };
}) {
const body: Record<string, string> = {};
if (options.instanceId) body.instanceId = options.instanceId;
if (options.metadata?.source !== undefined)
body.source = options.metadata.source;
if (options.metadata?.clientUploadTimestamp !== undefined)
body.clientUploadTimestamp = options.metadata.clientUploadTimestamp;
const req = {
userId: state.userId,
body,
file: options.file,
headers: {},
} as unknown as Request;
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(body: unknown) {
result.body = body;
return this;
},
} as unknown as Response;
await imageUploadHandler(req, res, () => {});
return result;
}
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 pngBuffer: Buffer;
let savedEnv: Record<string, string | undefined>;
beforeAll(async () => {
pngBuffer = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer();
});
beforeEach(() => {
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];
});
afterEach(() => {
for (const name of LEGACY_ENV_NAMES) {
if (savedEnv[name] === undefined) delete process.env[name];
else process.env[name] = savedEnv[name];
}
});
describe("terminal image upload route", () => {
it("rejects requests without an image file", async () => {
const response = await invoke({ instanceId: "tab-1" });
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({ code: "IMAGE_FILE_MISSING" });
});
it("requires a connected terminal in explicit remote-sftp mode", async () => {
state.settings["terminal_image_storage_mode"] = "remote-sftp";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(409);
expect(response.body).toMatchObject({
code: "IMAGE_TERMINAL_NOT_CONNECTED",
});
});
it("writes over SFTP in auto mode when a session is connected", async () => {
const { sftp, written, end } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(200);
const body = response.body as {
id: string;
filename: string;
shellPath: string;
storage: string;
};
expect(body.storage).toBe("remote-sftp");
expect(body.filename).toBe(`${body.id}.png`);
expect(body.shellPath).toBe(`/tmp/termix-images/${body.filename}`);
// Sharp normalized the upload to PNG before the write.
const writtenBytes = written.get(body.shellPath)!;
expect(writtenBytes.subarray(1, 4).toString()).toBe("PNG");
expect(end).toHaveBeenCalledTimes(1);
});
it("maps SFTP failures to 502 without leaking the raw remote error", async () => {
const { sftp, end } = fakeSftp({
writeError: new Error("Permission denied"),
});
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(502);
expect(response.body).toEqual({
error: "Failed to write image to the remote host",
code: "IMAGE_REMOTE_WRITE_FAILED",
});
expect(JSON.stringify(response.body)).not.toContain("Permission denied");
expect(end).toHaveBeenCalledTimes(1);
});
it("rejects undecodable image data", async () => {
const { sftp } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: Buffer.from("definitely not an image"),
mimetype: "image/png",
size: 23,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({ code: "IMAGE_DECODE_FAILED" });
});
describe("local mapped storage", () => {
let dir: string;
beforeEach(async () => {
// The settings validator rejects backslashes, so store the temp dir in
// POSIX form. Windows still resolves it for the real file checks.
dir = (
await fs.mkdtemp(path.join(os.tmpdir(), "termix-route-test-"))
).replace(/\\/g, "/");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("stores locally without a terminal session and hides backend paths", async () => {
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = dir;
state.settings["terminal_image_host_path"] = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(200);
const body = response.body as {
id: string;
filename: string;
shellPath: string;
storage: string;
};
expect(body.storage).toBe("local");
expect(body.shellPath).toBe(`/host-view/images/${body.filename}`);
expect(JSON.stringify(body)).not.toContain(dir);
const stored = await fs.readFile(path.join(dir, body.filename));
expect(stored.subarray(1, 4).toString()).toBe("PNG");
});
it("answers 507 when the configured image count cap is reached", async () => {
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = dir;
state.settings["terminal_image_host_path"] = "/host-view/images";
state.settings["terminal_image_max_count"] = "1";
await fs.writeFile(
path.join(dir, `${randomUUID()}.png`),
Buffer.alloc(16),
);
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(507);
expect(response.body).toMatchObject({
error: "Image storage limit reached",
code: "IMAGE_STORAGE_LIMIT_REACHED",
});
});
it("keeps legacy explicit local mappings on local mode", async () => {
process.env.TERMIX_IMAGE_DIR = dir;
process.env.TERMIX_IMAGE_HOST_PATH = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({ storage: "local" });
});
it("returns 503 when local storage inspection fails", async () => {
const blocked = `${dir}/blocked-file`;
await fs.writeFile(blocked, "not a directory");
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = blocked;
state.settings["terminal_image_host_path"] = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(503);
expect(response.body).toEqual({
error: "Unable to inspect local image storage",
code: "IMAGE_LOCAL_INSPECTION_FAILED",
});
expect(JSON.stringify(response.body)).not.toContain(blocked);
});
it("rejects auto mode when no verified storage capability is available", async () => {
process.env.DATA_DIR = dir;
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-missing",
});
expect(response.statusCode).toBe(503);
expect(response.body).toMatchObject({
code: "IMAGE_STORAGE_UNAVAILABLE",
});
});
});
describe("diagnostic metadata", () => {
interface UploadLogMeta {
operation?: string;
requestId?: string;
sequence?: number;
source?: string;
clientUploadTimestamp?: string;
serverReceivedAt?: string;
bytes?: number;
}
function uploadLogEntries(): UploadLogMeta[] {
return vi
.mocked(databaseLogger.info)
.mock.calls.filter(
(call) =>
(call[1] as UploadLogMeta | undefined)?.operation ===
"terminal_image_upload_received",
)
.map((call) => call[1] as UploadLogMeta);
}
beforeEach(() => {
vi.mocked(databaseLogger.info).mockClear();
});
it("logs correlation id, receipt time, and propagated source metadata", async () => {
const { sftp } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
metadata: {
source: "clipboard",
clientUploadTimestamp: "2026-08-15T12:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const entries = uploadLogEntries();
expect(entries).toHaveLength(1);
const meta = entries[0]!;
expect(meta.requestId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(typeof meta.sequence).toBe("number");
expect(meta.source).toBe("clipboard");
expect(meta.clientUploadTimestamp).toBe("2026-08-15T12:00:00.000Z");
expect(Number.isNaN(Date.parse(meta.serverReceivedAt ?? ""))).toBe(false);
expect(meta.bytes).toBe(pngBuffer.length);
});
it("logs a monotonically increasing upload sequence", async () => {
const { sftp } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
metadata: { source: "file" },
});
await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
metadata: { source: "clipboard" },
});
const sequences = uploadLogEntries().map((entry) => entry.sequence!);
expect(sequences).toHaveLength(2);
expect(sequences[1]).toBe(sequences[0]! + 1);
});
it("accepts missing metadata and keeps raw paths out of the log", async () => {
const dir = (
await fs.mkdtemp(path.join(os.tmpdir(), "termix-meta-test-"))
).replace(/\\/g, "/");
try {
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = dir;
state.settings["terminal_image_host_path"] = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(200);
const entries = uploadLogEntries();
expect(entries).toHaveLength(1);
expect(entries[0]!.source).toBeUndefined();
expect(entries[0]!.clientUploadTimestamp).toBeUndefined();
expect(JSON.stringify(entries[0])).not.toContain(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});
});
@@ -1,129 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, RequestHandler, Response } from "express";
import { databaseLogger } from "../../../utils/logger.js";
const state = vi.hoisted(() => ({
userId: "user-1",
settings: {} as Record<string, string>,
sessions: [] as unknown[],
}));
// A package shipped without the native binary for this architecture.
vi.mock("sharp", () => {
throw new Error(
'Could not load the "sharp" module using the darwin-x64 runtime',
);
});
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("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
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,
}),
createCurrentHostResolutionRepository: () => ({}),
createCurrentCommandHistoryRepository: () => ({}),
}));
const { default: router } =
await import("../../../database/routes/terminal.js");
interface RouteLayer {
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: RequestHandler }>;
};
}
const imageUploadLayer = (
router as unknown as { stack: RouteLayer[] }
).stack.find(
(layer) => layer.route?.path === "/image-upload" && layer.route.methods.post,
);
const imageUploadHandler =
imageUploadLayer!.route!.stack[imageUploadLayer!.route!.stack.length - 1]!
.handle;
async function invoke() {
const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const req = {
userId: state.userId,
body: { instanceId: "tab-1" },
file: { buffer, mimetype: "image/png", size: buffer.length },
headers: {},
} as unknown as Request;
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(body: unknown) {
result.body = body;
return this;
},
} as unknown as Response;
await imageUploadHandler(req, res, () => {});
return result;
}
beforeEach(() => {
vi.mocked(databaseLogger.error).mockClear();
state.settings = { terminal_image_storage_mode: "remote-sftp" };
state.sessions = [{ tabInstanceId: "tab-1", isConnected: true, sshConn: {} }];
});
describe("terminal image upload route when sharp cannot be loaded", () => {
it("imports the router instead of failing at module scope", () => {
expect(imageUploadHandler).toBeTypeOf("function");
});
it("answers 503 and logs the reason rather than throwing", async () => {
const response = await invoke();
expect(response.statusCode).toBe(503);
expect(response.body).toMatchObject({
code: "IMAGE_PROCESSING_UNAVAILABLE",
});
expect(databaseLogger.error).toHaveBeenCalledWith(
"Image processing unavailable: sharp failed to load",
expect.anything(),
expect.objectContaining({
operation: "terminal_image_upload_sharp_unavailable",
}),
);
});
it("keeps answering 503 on later uploads without retrying the import", async () => {
await invoke();
const response = await invoke();
expect(response.statusCode).toBe(503);
expect(response.body).toMatchObject({
code: "IMAGE_PROCESSING_UNAVAILABLE",
});
});
});
@@ -1,76 +0,0 @@
import { describe, expect, it } from "vitest";
import {
exceedsImageStorageLimit,
exceedsNormalizedImageSize,
imageExtensionForFormat,
createConcurrencyLimiter,
isExpiredImage,
isImageFilename,
} from "../../../database/routes/terminal-image-utils.js";
describe("terminal image utilities", () => {
it("accepts UUID-based image filenames", () => {
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506.jpeg")).toBe(
true,
);
});
it("rejects unsafe or unrelated filenames", () => {
expect(isImageFilename("../secrets.txt")).toBe(false);
expect(isImageFilename("not-an-image.jpeg")).toBe(false);
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506")).toBe(false);
});
it("maps decoded raster formats while excluding SVG", () => {
expect(imageExtensionForFormat("jpeg")).toBe("jpg");
expect(imageExtensionForFormat("heif")).toBe("heif");
expect(imageExtensionForFormat("tiff")).toBe("tiff");
expect(imageExtensionForFormat("svg")).toBeUndefined();
expect(imageExtensionForFormat(undefined)).toBeUndefined();
});
it("rejects normalized output beyond the byte ceiling", () => {
expect(exceedsNormalizedImageSize(10_000_001, 10_000_000)).toBe(true);
expect(exceedsNormalizedImageSize(10_000_000, 10_000_000)).toBe(false);
});
it("expires files older than the configured TTL", () => {
expect(isExpiredImage(1_000, 3_000, 1_000)).toBe(true);
expect(isExpiredImage(2_500, 3_000, 1_000)).toBe(false);
});
it("treats a zero TTL as retention disabled", () => {
expect(isExpiredImage(1_000, 999_999, 0)).toBe(false);
});
it("bounds the admission queue", async () => {
const limiter = createConcurrencyLimiter(1, 0);
const release = await limiter.acquire();
await expect(limiter.acquire()).rejects.toThrow("queue is full");
release();
});
it("rejects uploads that exceed the count or byte limit", () => {
expect(exceedsImageStorageLimit(100, 10, 1, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 101, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 100, 100, 1000)).toBe(false);
});
it("bounds concurrent work", async () => {
const limiter = createConcurrencyLimiter(1);
let active = 0;
let peak = 0;
const task = async () => {
const release = await limiter.acquire();
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
release();
};
await Promise.all([task(), task(), task()]);
expect(peak).toBe(1);
expect(limiter.active).toBe(0);
});
});
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, RequestHandler, Response } from "express";
import { TOUCH_INPUT_DEFAULTS } from "../../../../types/touch-input-settings.js";
const state = vi.hoisted(() => ({
userId: "admin",
admins: new Set(["admin"]),
stored: null as string | null,
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { error: vi.fn() },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => ({ id, isAdmin: state.admins.has(id) }),
}),
createCurrentSettingsRepository: () => ({
get: async () => state.stored,
set: async (_key: string, value: string) => {
state.stored = value;
},
}),
}));
const { registerTouchInputSettingsRoutes } =
await import("../../../database/routes/touch-input-settings-routes.js");
type Registered = { method: string; path: string; handler: RequestHandler };
const registered: Registered[] = [];
const router = {
get: (path: string, ...handlers: RequestHandler[]) =>
registered.push({ method: "get", path, handler: handlers.at(-1)! }),
patch: (path: string, ...handlers: RequestHandler[]) =>
registered.push({ method: "patch", path, handler: handlers.at(-1)! }),
} as unknown as import("express").Router;
registerTouchInputSettingsRoutes(router, (_req, _res, next) => next());
async function invoke(method: string, body: unknown = {}) {
const handler = registered.find((entry) => entry.method === method)!.handler;
const req = { userId: state.userId, body } as unknown as Request;
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(bodyValue: unknown) {
result.body = bodyValue;
return this;
},
} as Response;
await handler(req, res, () => {});
return result;
}
beforeEach(() => {
state.userId = "admin";
state.stored = null;
});
describe("touch input settings routes", () => {
it("allows authenticated non-admin users to read normalized defaults", async () => {
state.userId = "user";
const response = await invoke("get");
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(TOUCH_INPUT_DEFAULTS);
});
it("only allows admins to write", async () => {
state.userId = "user";
const response = await invoke("patch", { enabled: false });
expect(response.statusCode).toBe(403);
expect(state.stored).toBeNull();
});
it("rejects out-of-range values and persists normalized updates", async () => {
const invalid = await invoke("patch", { maximumTicksPerFrame: 101 });
expect(invalid.statusCode).toBe(400);
const valid = await invoke("patch", {
dragThresholdPx: 9,
momentumEnabled: false,
});
expect(valid.statusCode).toBe(200);
expect(JSON.parse(state.stored!)).toEqual({
...TOUCH_INPUT_DEFAULTS,
dragThresholdPx: 9,
momentumEnabled: false,
});
});
});