mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -1,76 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { CommandHistoryRepository } from "../../../database/repositories/command-history-repository.js";
|
||||
|
||||
describe("CommandHistoryRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<CommandHistoryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new CommandHistoryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists unique commands by latest execution", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z");
|
||||
await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z");
|
||||
await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z");
|
||||
await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z");
|
||||
|
||||
expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([
|
||||
"ls",
|
||||
"pwd",
|
||||
]);
|
||||
expect(await repo.listCommandsForHost("user-1", 1)).toEqual([
|
||||
"ls",
|
||||
"pwd",
|
||||
"ls",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deletes commands by command, host, host list, and user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create("user-1", 1, "ls");
|
||||
await repo.create("user-1", 1, "ls");
|
||||
await repo.create("user-1", 2, "pwd");
|
||||
await repo.create("user-2", 3, "whoami");
|
||||
expect(writeCount).toBe(4);
|
||||
|
||||
expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0);
|
||||
expect(writeCount).toBe(4);
|
||||
|
||||
expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2);
|
||||
expect(writeCount).toBe(5);
|
||||
|
||||
expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([3])).toBe(1);
|
||||
expect(writeCount).toBe(7);
|
||||
|
||||
await repo.create("user-1", 1, "date");
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(writeCount).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -101,8 +101,8 @@ vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
state.control.push(args);
|
||||
},
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findCredentialByIdForUser: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { SSHAuthManager } from "../../../hosts/connect/terminal-prompt.js";
|
||||
|
||||
function createManager() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
|
||||
const manager = new SSHAuthManager({
|
||||
userId: "user-1",
|
||||
ws,
|
||||
hostId: 1,
|
||||
isKeyboardInteractive: false,
|
||||
keyboardInteractiveResponded: false,
|
||||
keyboardInteractiveFinish: null,
|
||||
totpPromptSent: false,
|
||||
warpgateAuthPromptSent: false,
|
||||
totpTimeout: null,
|
||||
warpgateAuthTimeout: null,
|
||||
totpAttempts: 0,
|
||||
});
|
||||
return { manager, sent };
|
||||
}
|
||||
|
||||
describe("SSHAuthManager.handleKeyboardInteractive", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("routes a TOTP verification prompt to the totp flow", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Verification code: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "TOTP verification required",
|
||||
},
|
||||
},
|
||||
{ type: "totp_required", prompt: "Verification code: " },
|
||||
]);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Choose [1] Push, or [2] TOTP: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("silently auto-answers a plain password prompt when a stored password exists", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Password: ", echo: false }],
|
||||
finish,
|
||||
{ username: "root", password: "hunter2", authType: "password" },
|
||||
);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2"]);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Press enter to send Push request: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
|
||||
manager.context.keyboardInteractiveFinish?.([""]);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith([""]);
|
||||
});
|
||||
|
||||
it("uses a longer timeout for push-style prompts than generic prompts", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(180001);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(120000);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes a FortiToken prompt to the totp flow instead of the push-confirm flow", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[
|
||||
{
|
||||
prompt:
|
||||
"Enter your Token or type 'push' to receive a push notification: ",
|
||||
echo: true,
|
||||
},
|
||||
],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "TOTP verification required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "totp_required",
|
||||
prompt:
|
||||
"Enter your Token or type 'push' to receive a push notification: ",
|
||||
},
|
||||
]);
|
||||
|
||||
manager.context.keyboardInteractiveFinish?.(["push"]);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith(["push"]);
|
||||
});
|
||||
|
||||
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"Warpgate Authentication",
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
"",
|
||||
[{ prompt: "Press enter once done: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Warpgate authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "warpgate_auth_required",
|
||||
url: "https://warpgate.example.com/auth",
|
||||
securityKey: "AB12",
|
||||
instructions:
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -52,8 +52,8 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
getSession: (sessionId: string) => {
|
||||
const session = state.sshSessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
@@ -9,57 +9,10 @@ vi.mock("fs/promises", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
MemoryAgent,
|
||||
FilteredAgent,
|
||||
resolveAgentSocket,
|
||||
} from "../../hosts/terminal-auth-helpers.js";
|
||||
|
||||
describe("MemoryAgent", () => {
|
||||
it("serves identities and signatures over the agent protocol", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const parsed = ssh2Pkg.utils.parseKey(
|
||||
privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||
);
|
||||
expect(parsed).not.toBeInstanceOf(Error);
|
||||
|
||||
const agent = new MemoryAgent(parsed as ParsedKey);
|
||||
const stream = await new Promise<NodeJS.ReadWriteStream>(
|
||||
(resolve, reject) => {
|
||||
agent.getStream((error, result) => {
|
||||
if (error || !result)
|
||||
reject(error ?? new Error("Missing agent stream"));
|
||||
else resolve(result);
|
||||
});
|
||||
},
|
||||
);
|
||||
const client = new ssh2Pkg.AgentProtocol(true);
|
||||
client.pipe(stream).pipe(client);
|
||||
|
||||
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
|
||||
client.getIdentities((error, keys) => {
|
||||
if (error || !keys) reject(error ?? new Error("Missing identities"));
|
||||
else resolve(keys);
|
||||
});
|
||||
});
|
||||
expect(identities).toHaveLength(1);
|
||||
expect(identities[0].getPublicSSH()).toEqual(
|
||||
(parsed as ParsedKey).getPublicSSH(),
|
||||
);
|
||||
|
||||
const data = Buffer.from("forwarded-agent-test");
|
||||
const signature = await new Promise<Buffer>((resolve, reject) => {
|
||||
client.sign(identities[0], data, (error, result) => {
|
||||
if (error || !result) reject(error ?? new Error("Missing signature"));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
|
||||
|
||||
client.destroy();
|
||||
stream.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentSocket", () => {
|
||||
const originalEnv = process.env.SSH_AUTH_SOCK;
|
||||
const originalPlatform = process.platform;
|
||||
@@ -228,58 +181,3 @@ describe("FilteredAgent", () => {
|
||||
expect(identities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryAgent unsupported extension framing", () => {
|
||||
it.each([false, true])(
|
||||
"can enumerate keys after rejecting session-bind (fragmented=%s)",
|
||||
async (fragmented) => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
const key = ssh2Pkg.utils.parseKey(
|
||||
privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||
) as ParsedKey;
|
||||
const agent = new MemoryAgent(key);
|
||||
const stream = await new Promise<NodeJS.ReadWriteStream>(
|
||||
(resolve, reject) => {
|
||||
agent.getStream((error, result) =>
|
||||
error || !result ? reject(error) : resolve(result),
|
||||
);
|
||||
},
|
||||
);
|
||||
const replies: Buffer[] = [];
|
||||
let buffered = Buffer.alloc(0);
|
||||
stream.on("data", (data: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, data]);
|
||||
while (
|
||||
buffered.length >= 4 &&
|
||||
buffered.length >= buffered.readUInt32BE(0) + 4
|
||||
) {
|
||||
const end = buffered.readUInt32BE(0) + 4;
|
||||
replies.push(buffered.subarray(4, end));
|
||||
buffered = buffered.subarray(end);
|
||||
}
|
||||
});
|
||||
const extension = Buffer.from("session-bind@openssh.com");
|
||||
const request = Buffer.alloc(4 + 1 + 4 + extension.length);
|
||||
request.writeUInt32BE(request.length - 4, 0);
|
||||
request[4] = 27;
|
||||
request.writeUInt32BE(extension.length, 5);
|
||||
extension.copy(request, 9);
|
||||
const identities = Buffer.from([0, 0, 0, 1, 11]);
|
||||
try {
|
||||
const requests = Buffer.concat([request, identities]);
|
||||
if (fragmented) {
|
||||
stream.write(requests.subarray(0, 7));
|
||||
stream.write(requests.subarray(7));
|
||||
} else stream.write(requests);
|
||||
await vi.waitFor(() => expect(replies).toHaveLength(2));
|
||||
expect(replies[0][0]).toBe(5); // Unsupported extension remains refused.
|
||||
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
|
||||
expect(replies[1].readUInt32BE(1)).toBe(1);
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* The terminal WS server's lifecycle, which the ssh-terminal plugin drives.
|
||||
*
|
||||
* There is no terminal port any more: core serves the socket at
|
||||
* /plugin-ws/ssh-terminal/terminal and the plugin hands each upgrade to the
|
||||
* WebSocketServer this module owns. So the thing worth asserting changed with
|
||||
* it. It used to be "the port stops listening"; it is now "the module binds
|
||||
* nothing on import, and disabling the plugin closes the live sessions", which
|
||||
* is what disabling a transport owner has to mean when core owns the socket.
|
||||
*
|
||||
* The terminal module is heavy (it pulls in the whole SSH stack), so this
|
||||
* exercises only the lifecycle surface.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentCollabRoomRepository: () => ({}),
|
||||
createCurrentSessionShareRepository: () => ({}),
|
||||
createCurrentSettingsRepository: () => ({}),
|
||||
createCurrentSessionRecordingRepository: () => ({}),
|
||||
}));
|
||||
|
||||
describe("terminal server lifecycle", () => {
|
||||
it("binds no port of its own", async () => {
|
||||
const terminal = await import("../../../hosts/terminal/index.js");
|
||||
|
||||
// The old module exported a port and a listen() call. Both are gone, and
|
||||
// their absence is the contract: a plugin that binds its own port is
|
||||
// exactly what A4 removed.
|
||||
expect("TERMINAL_WS_PORT" in terminal).toBe(false);
|
||||
expect("startTerminalServer" in terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes an upgrade handler for core to route to", async () => {
|
||||
const { handleTerminalUpgrade } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
expect(typeof handleTerminalUpgrade).toBe("function");
|
||||
});
|
||||
|
||||
it("is idempotent on stop, so a double deactivate is safe", async () => {
|
||||
const { stopTerminalServer } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,510 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Stub all external imports before loading the module under test
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: 1 });
|
||||
const mockUpdateEnded = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionRecordingRepository: () => ({
|
||||
create: mockCreate,
|
||||
updateEnded: mockUpdateEnded,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock individual fs.promises methods via a stub object
|
||||
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
||||
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
default: {
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
},
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
}));
|
||||
|
||||
const { sessionManager, isMessageAllowedForParticipant } =
|
||||
await import("../../../hosts/terminal/session-manager.js");
|
||||
|
||||
// Minimal fake WebSocket - only the surface session-manager touches.
|
||||
function makeFakeWs(readyState = 1 /* OPEN */) {
|
||||
return {
|
||||
readyState,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
} as unknown as import("ws").WebSocket;
|
||||
}
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSED = 3;
|
||||
|
||||
describe("TerminalSessionManager - session logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Re-apply resolved values after clearAllMocks
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("createSession stores sessionLoggingEnabled=true by default", () => {
|
||||
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.sessionLoggingEnabled).toBe(true);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("createSession stores sessionLoggingEnabled=false when passed", () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.sessionLoggingEnabled).toBe(false);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("does not write log file when sessionLoggingEnabled=false", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
sessionManager.bufferOutput(id, "some output");
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes log file and inserts DB row when sessionLoggingEnabled=true", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
sessionManager.bufferOutput(id, "terminal output data");
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).toHaveBeenCalledOnce();
|
||||
expect(mockCreate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not write log file when buffer is empty", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bufferOutput trims old data when exceeding 512KB", () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const chunk = "x".repeat(300 * 1024);
|
||||
sessionManager.bufferOutput(id, chunk);
|
||||
sessionManager.bufferOutput(id, chunk);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalSessionManager - multiplayer participants", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function createConnectedSession(): string {
|
||||
const id = sessionManager.createSession(
|
||||
"owner-1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// Mark connected without a real ssh2 stream - only isConnected is read
|
||||
// by attachWs/joinAsParticipant.
|
||||
const session = sessionManager.getSession(id)!;
|
||||
session.isConnected = true;
|
||||
return id;
|
||||
}
|
||||
|
||||
it("joinAsParticipant adds a participant without evicting the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.participants.size).toBe(2);
|
||||
const ownerParticipant = sessionManager.getParticipantForWs(
|
||||
session!,
|
||||
ownerWs,
|
||||
);
|
||||
expect(ownerParticipant?.isOwner).toBe(true);
|
||||
// The join is announced to everyone already in the session - and that is
|
||||
// the only unsolicited message the owner receives.
|
||||
expect(ownerWs.send).toHaveBeenCalledTimes(1);
|
||||
const announced = JSON.parse(
|
||||
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
|
||||
);
|
||||
expect(announced.type).toBe("participants");
|
||||
expect(announced.participants).toHaveLength(2);
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
const aliceWs = makeFakeWs();
|
||||
const bobWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, aliceWs, {
|
||||
userId: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
})!;
|
||||
sessionManager.joinAsParticipant(id, bobWs, {
|
||||
userId: "bob",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
});
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", "alice");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", null);
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("disconnectShareParticipants revokes only the selected share participants", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
const aliceWs = makeFakeWs();
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
const session = sessionManager.joinAsParticipant(id, aliceWs, {
|
||||
userId: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
})!;
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
});
|
||||
|
||||
expect(
|
||||
sessionManager.disconnectShareParticipants(id, "stage-share", {
|
||||
userId: "alice",
|
||||
reason: "Removed",
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
|
||||
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
|
||||
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
|
||||
true,
|
||||
);
|
||||
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||
expect(
|
||||
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs(WS_OPEN);
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const openGuestWs = makeFakeWs(WS_OPEN);
|
||||
const closedGuestWs = makeFakeWs(WS_CLOSED);
|
||||
sessionManager.joinAsParticipant(id, openGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
sessionManager.joinAsParticipant(id, closedGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.broadcast(id, { type: "data", data: "hello" });
|
||||
|
||||
expect(ownerWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(openGuestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(closedGuestWs.send).not.toHaveBeenCalled();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast does not throw if a socket's send throws", () => {
|
||||
const id = createConnectedSession();
|
||||
const throwingWs = makeFakeWs(WS_OPEN);
|
||||
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("send failed");
|
||||
});
|
||||
sessionManager.attachWs(id, "owner-1", throwingWs);
|
||||
|
||||
expect(() =>
|
||||
sessionManager.broadcast(id, { type: "data", data: "x" }),
|
||||
).not.toThrow();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast is a no-op for a nonexistent session", () => {
|
||||
expect(() =>
|
||||
sessionManager.broadcast("does-not-exist", { type: "data" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("owner detach arms the idle timeout (existing behavior)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.detachWs(id);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.detachTimeout).not.toBeNull();
|
||||
expect(session?.lastDetachedAt).not.toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.removeParticipant(id, guestWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.detachTimeout).toBeNull();
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.removeParticipant(id, ownerWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("destroySession cleans up all participants, not just the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalled();
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
|
||||
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.ownerEndSession(id, "owner ended the session");
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: "sessionTerminatedByOwner",
|
||||
reason: "owner ended the session",
|
||||
}),
|
||||
);
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMessageAllowedForParticipant", () => {
|
||||
it("allows any message type for the owner or when there is no participant", () => {
|
||||
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: true, permissionLevel: "read-write" },
|
||||
"resize",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops input from a read-only participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"input",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows input from a read-write non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
"input",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ping and disconnect for any non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"ping",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"disconnect",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
|
||||
for (const type of [
|
||||
"resize",
|
||||
"totp_response",
|
||||
"password_response",
|
||||
"tmux_attach",
|
||||
"tmux_detach",
|
||||
"get_cwd",
|
||||
"vault_start_auth",
|
||||
"opkssh_start_auth",
|
||||
]) {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
type,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ctx.audit.record: a plugin writes an audit line under its own action name,
|
||||
* and the user it names is always the runtime's actor, never one the plugin
|
||||
* picked.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string) {
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: "Audit Fixture",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
auditEntries.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.audit.record", () => {
|
||||
it("writes the plugin's action for the acting user", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await runAsActor("user-3", "request", () =>
|
||||
ctx.audit.record({
|
||||
action: "ssh_connect",
|
||||
resourceType: "host",
|
||||
resourceId: "12",
|
||||
resourceName: "root@10.0.0.1:22",
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
expect(auditEntries).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: "user-3",
|
||||
action: "ssh_connect",
|
||||
resourceType: "host",
|
||||
resourceId: "12",
|
||||
resourceName: "root@10.0.0.1:22",
|
||||
details: "via plugin audit-fixture",
|
||||
success: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the plugin as the resource and system as the user", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({ action: "cleanup", success: false });
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
userId: "system",
|
||||
resourceType: "plugin",
|
||||
resourceId: "audit-fixture",
|
||||
resourceName: "Audit Fixture",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@ const h = vi.hoisted(() => ({
|
||||
updates: [] as Array<Record<string, unknown>>,
|
||||
updateResult: null as Record<string, unknown> | null,
|
||||
users: [] as Array<{ id: string; username: string }>,
|
||||
activities: [] as Array<Record<string, unknown>>,
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -90,6 +93,23 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
listDecryptedByUserId: async () => h.ownedHosts,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../services/recent-activity.js", () => ({
|
||||
recordRecentActivity: async (
|
||||
userId: string,
|
||||
entry: Record<string, unknown>,
|
||||
) => {
|
||||
h.activities.push({ userId, ...entry });
|
||||
return { status: "logged", id: 1 };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
hostSessionStatus: {
|
||||
register: (hostId: number) => {
|
||||
h.sessions.push(hostId);
|
||||
return () => h.released.push(hostId);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
@@ -347,3 +367,40 @@ describe("ctx.hosts", () => {
|
||||
expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts session tracking and activity", () => {
|
||||
it("counts a live session through core and releases it", () => {
|
||||
h.sessions = [];
|
||||
h.released = [];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const release = hosts.trackSession(4);
|
||||
expect(h.sessions).toEqual([4]);
|
||||
release();
|
||||
expect(h.released).toEqual([4]);
|
||||
});
|
||||
|
||||
it("refuses to track a session without hosts:read declared", () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() => hosts.trackSession(4)).toThrow(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("records activity for the acting user only", async () => {
|
||||
h.activities = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
h.actor = "user-7";
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await hosts.recordActivity(4, "terminal", "web-01");
|
||||
expect(h.activities).toEqual([
|
||||
{ userId: "user-7", type: "terminal", hostId: 4, hostName: "web-01" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* The ctx.ssh members an interactive transport (the terminal) needs: host
|
||||
* resolution with secrets, prepare with its provider hooks bound, and the
|
||||
* browser sign-in start and cancel that used to be reached by import.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
actor: "user-1" as string | undefined,
|
||||
resolved: [] as Array<{ by: string; ref: unknown; userId: string }>,
|
||||
built: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/connect/core-providers.js", () => ({
|
||||
ensureCoreSshAuthProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (id: number, userId: string) => {
|
||||
h.resolved.push({ by: "id", ref: id, userId });
|
||||
return id === 404 ? null : { id, ip: "10.0.0.1", password: "pw" };
|
||||
},
|
||||
resolveHostBySyncId: async (syncId: string, userId: string) => {
|
||||
h.resolved.push({ by: "syncId", ref: syncId, userId });
|
||||
return { id: 9, ip: "10.0.0.9" };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/build-connect-config.js", () => ({
|
||||
buildConnectConfig: async (
|
||||
_host: unknown,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
h.built.push(options);
|
||||
return {
|
||||
config: { host: "10.0.0.1" },
|
||||
outcome: { status: "ready" },
|
||||
env: { interactive: options.interactive },
|
||||
provider: {
|
||||
type: "fixture-auth",
|
||||
onBanner: (banner: string, _host: unknown, env: unknown) => ({
|
||||
action: "hold",
|
||||
timeoutMs: 1,
|
||||
message: banner,
|
||||
details: { env },
|
||||
}),
|
||||
onAuthFailed: () => ({ status: "retry", patch: {}, message: "again" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async (hostId: number) =>
|
||||
hostId === 1
|
||||
? {
|
||||
id: 1,
|
||||
name: "box",
|
||||
ip: "10.0.0.1",
|
||||
username: "root",
|
||||
authType: "fixture-auth",
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginSsh } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { PluginCapabilityError, PluginSshInteractionError } =
|
||||
await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const ALL = ["ssh:connect", "credentials:use"];
|
||||
|
||||
function sshWith(capabilities = ALL) {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never,
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
return { ssh, audit };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(ALL);
|
||||
h.actor = "user-1";
|
||||
h.resolved = [];
|
||||
h.built = [];
|
||||
resetSshAuthRegistryForTests();
|
||||
});
|
||||
|
||||
describe("ctx.ssh.resolveHost", () => {
|
||||
it("resolves by sync id first, as the acting user, and audits", async () => {
|
||||
const { ssh, audit } = sshWith();
|
||||
expect(await ssh.resolveHost(3, { syncId: "abc" })).toMatchObject({
|
||||
id: 9,
|
||||
});
|
||||
expect(await ssh.resolveHost(3)).toMatchObject({ id: 3, password: "pw" });
|
||||
expect(h.resolved).toEqual([
|
||||
{ by: "syncId", ref: "abc", userId: "user-1" },
|
||||
{ by: "id", ref: 3, userId: "user-1" },
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith("ssh_resolve_host", "host 3", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers null for a host this user cannot reach", async () => {
|
||||
const { ssh } = sshWith();
|
||||
expect(await ssh.resolveHost(404)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses without credentials:use", async () => {
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
const { ssh } = sshWith(["ssh:connect"]);
|
||||
await expect(ssh.resolveHost(3)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.resolved).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh.prepare", () => {
|
||||
it("passes the terminal purpose, interactivity and host key socket through", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const socket = { send: vi.fn() };
|
||||
await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{
|
||||
purpose: "terminal",
|
||||
client: {},
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
},
|
||||
);
|
||||
expect(h.built[0]).toMatchObject({
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("hands back the provider's hooks bound to this host", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const prepared = await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{ client: {}, interactive: true },
|
||||
);
|
||||
expect(prepared.authType).toBe("fixture-auth");
|
||||
expect(prepared.onBanner?.("waiting")).toMatchObject({
|
||||
action: "hold",
|
||||
message: "waiting",
|
||||
details: { env: { interactive: true } },
|
||||
});
|
||||
expect(
|
||||
prepared.onAuthFailed?.({
|
||||
error: new Error("x"),
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
}),
|
||||
).toMatchObject({ status: "retry" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh browser sign-in", () => {
|
||||
it("starts the interaction on the host's own provider", async () => {
|
||||
const started = vi.fn(async () => {});
|
||||
registerSshAuthProvider({
|
||||
type: "fixture-auth",
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction: "fixture",
|
||||
startInteraction: started,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
const { ssh } = sshWith();
|
||||
await ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
});
|
||||
expect(started).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
host: { name: "box", ip: "10.0.0.1", username: "root" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("says why when nothing can start it", async () => {
|
||||
const { ssh } = sshWith();
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginSshInteractionError);
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 2,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toThrow("Host not found");
|
||||
});
|
||||
|
||||
it("cancels on every provider that owns the interaction", async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const other = vi.fn();
|
||||
for (const [type, interaction, cancel] of [
|
||||
["a", "fixture", first],
|
||||
["b", "fixture", second],
|
||||
["c", "elsewhere", other],
|
||||
] as const) {
|
||||
registerSshAuthProvider({
|
||||
type,
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction,
|
||||
cancelInteraction: cancel,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
}
|
||||
const { ssh } = sshWith();
|
||||
await ssh.cancelInteraction("fixture", { requestId: "r1" });
|
||||
expect(first).toHaveBeenCalledWith({ userId: "user-1", requestId: "r1" });
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
clearServiceRegistry,
|
||||
createServiceHandle,
|
||||
getRegistration,
|
||||
getServiceImplementation,
|
||||
listServices,
|
||||
provideService,
|
||||
resolveRequirements,
|
||||
@@ -85,6 +86,19 @@ describe("service registry", () => {
|
||||
expect(revokeService("nothing.here")).toBe(false);
|
||||
});
|
||||
|
||||
it("hands core the implementation of a compatible version, or nothing", () => {
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^1.0.0"),
|
||||
).toBeUndefined();
|
||||
const registration = register({ version: "1.4.0" });
|
||||
expect(getServiceImplementation("testplugin.greet", "^1.0.0")).toBe(
|
||||
registration.implementation,
|
||||
);
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^2.0.0"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("resolution", () => {
|
||||
it("is satisfied when the version is in range", () => {
|
||||
register({ version: "1.2.0" });
|
||||
|
||||
@@ -37,6 +37,13 @@ vi.mock("../../utils/auth-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: (userId: string) =>
|
||||
userId === state.userId ? Buffer.from("key") : null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
@@ -190,6 +197,54 @@ describe("ctx.ws authentication", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws optional auth", () => {
|
||||
function register(seen: Array<Record<string, unknown>>) {
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/mixed",
|
||||
(connection) => {
|
||||
seen.push({
|
||||
userId: connection.userId,
|
||||
unlocked: connection.isDataUnlocked(),
|
||||
clientIp: connection.clientIp,
|
||||
requestOrigin: connection.requestOrigin,
|
||||
});
|
||||
(connection.socket as WebSocket).send("hi");
|
||||
},
|
||||
DECLARED,
|
||||
{ public: true, optionalAuth: true },
|
||||
);
|
||||
}
|
||||
|
||||
it("names the user when a valid token came with the upgrade", async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
register(seen);
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/mixed`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen[0]).toMatchObject({ userId: state.userId, unlocked: true });
|
||||
expect(typeof seen[0].clientIp).toBe("string");
|
||||
expect(String(seen[0].requestOrigin)).toMatch(/^http/);
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("still serves a guest, with no user and nothing unlocked", async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
register(seen);
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/mixed`,
|
||||
authProtocols("nope"),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen[0]).toMatchObject({ userId: "", unlocked: false });
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws capability", () => {
|
||||
it("refuses the upgrade when network:serve is not granted", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* dispatchLoginEvent used to POST to the metrics service on localhost:30005
|
||||
* with an internal auth token. Host metrics is a plugin now and has no port,
|
||||
* so it publishes on the plugin event bus and the plugin subscribes.
|
||||
*
|
||||
* What is asserted changed with it: the payload a subscriber receives, and
|
||||
* that a throwing subscriber cannot fail the login that caused the event.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
describe("dispatchLoginEvent", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("publishes the login details a subscriber needs", async () => {
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, (payload) => {
|
||||
received.push(payload);
|
||||
});
|
||||
|
||||
try {
|
||||
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(received).toEqual([
|
||||
{ hostId: 42, userId: "user-1", sshUser: "root", fromIp: "10.0.0.5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not log a warning on the happy path", async () => {
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never sends the event over the network", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
// The whole point of the move: no service-to-service HTTP call, and no
|
||||
// internal shared secret to carry on it.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not propagate a subscriber's throw to the caller", async () => {
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, () => {
|
||||
throw new Error("subscriber exploded");
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* The SSH terminal settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: an install's session timeout, command history
|
||||
* switch, touch tuning and image storage settings, and every host that turned
|
||||
* a terminal switch off, must come through into the plugin's own settings.
|
||||
* Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_terminal: number | null;
|
||||
enable_terminal_toolbar: number | null;
|
||||
enable_command_history: number | null;
|
||||
}
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
const pluginRows: Row[] = [];
|
||||
const hostRows: HostRow[] = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
pluginInstalled && id === "ssh-terminal" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) =>
|
||||
row.enable_terminal === 0 ||
|
||||
row.enable_terminal_toolbar === 0 ||
|
||||
row.enable_command_history === 0,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
plaintext.startsWith("sysenc:") ? plaintext : `sysenc:${plaintext}`,
|
||||
}));
|
||||
|
||||
const { runSshTerminalSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/ssh-terminal-settings-migration.js");
|
||||
|
||||
function admin(key: string): unknown {
|
||||
const row = find("ssh-terminal", "admin", null, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
function host(id: number, key: string): unknown {
|
||||
const row = find("ssh-terminal", "host", String(id), key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
coreSettings.clear();
|
||||
pluginRows.length = 0;
|
||||
hostRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runSshTerminalSettingsMigration", () => {
|
||||
it("moves the admin settings with their types, the local dir as a secret", async () => {
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
coreSettings.set("terminal_session_persistence_enabled", "false");
|
||||
coreSettings.set("command_history_enabled", "false");
|
||||
coreSettings.set(
|
||||
"touch_input_settings",
|
||||
JSON.stringify({ enabled: false, dragThresholdPx: 12 }),
|
||||
);
|
||||
coreSettings.set("terminal_image_storage_mode", "local");
|
||||
coreSettings.set("terminal_image_local_dir", "/data/images");
|
||||
coreSettings.set("terminal_image_max_count", "50");
|
||||
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(result.moved).toHaveLength(7);
|
||||
expect(admin("sessionTimeoutMinutes")).toBe(120);
|
||||
expect(admin("sessionPersistence")).toBe(false);
|
||||
expect(admin("commandHistoryEnabled")).toBe(false);
|
||||
expect(admin("touchInput")).toEqual({
|
||||
enabled: false,
|
||||
dragThresholdPx: 12,
|
||||
});
|
||||
expect(admin("imageStorageMode")).toBe("local");
|
||||
expect(admin("imageMaxCount")).toBe(50);
|
||||
const localDir = find("ssh-terminal", "admin", null, "imageLocalDir");
|
||||
expect(localDir?.encrypted).toBe(true);
|
||||
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
|
||||
});
|
||||
|
||||
it("copies each host that switched something off, keeping the rest on", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
enable_terminal: 1,
|
||||
enable_terminal_toolbar: 0,
|
||||
enable_command_history: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
enable_terminal: 0,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
enable_terminal: 1,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(2);
|
||||
expect(host(1, "enableTerminal")).toBe(true);
|
||||
expect(host(1, "enableTerminalToolbar")).toBe(false);
|
||||
expect(host(1, "enableCommandHistory")).toBe(true);
|
||||
expect(host(2, "enableTerminal")).toBe(false);
|
||||
expect(host(2, "enableCommandHistory")).toBe(false);
|
||||
// All on is the plugin's default, so it needs no row.
|
||||
expect(host(3, "enableTerminal")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after the admin changed a value", async () => {
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_terminal: 0,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 1,
|
||||
});
|
||||
await runSshTerminalSettingsMigration();
|
||||
|
||||
const changed = find(
|
||||
"ssh-terminal",
|
||||
"admin",
|
||||
null,
|
||||
"sessionTimeoutMinutes",
|
||||
);
|
||||
changed!.value = JSON.stringify(15);
|
||||
const rowsBefore = pluginRows.length;
|
||||
|
||||
const second = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(second.hostsSkipped).toBe(1);
|
||||
expect(pluginRows).toHaveLength(rowsBefore);
|
||||
expect(admin("sessionTimeoutMinutes")).toBe(15);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
pluginInstalled = false;
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user