mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { hostImportNormalizer } from "../../src/backend/host-import.js";
|
||||
import type {
|
||||
LiveSessionsV1,
|
||||
TerminalHistoryV1,
|
||||
} from "../../src/backend/services.js";
|
||||
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("ssh-terminal activate", () => {
|
||||
it("serves the terminal socket", async () => {
|
||||
server = await startServer();
|
||||
expect(server.mock.wsRoutes).toEqual([{ path: "/terminal", raw: false }]);
|
||||
});
|
||||
|
||||
it("provides sessions.live and terminal.history", async () => {
|
||||
server = await startServer();
|
||||
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
|
||||
expect(live.getSession("missing")).toBeNull();
|
||||
expect(live.listForUser("user-1")).toEqual([]);
|
||||
expect(live.write("missing", "ls\r")).toBe(false);
|
||||
expect(live.idleTimeoutMinutes()).toBe(30);
|
||||
|
||||
await server.request("POST", "/command-history", {
|
||||
body: { hostId: 1, command: "df -h" },
|
||||
});
|
||||
const history = server.mock.services.get(
|
||||
"terminal.history",
|
||||
) as TerminalHistoryV1;
|
||||
const listed = await server.mock.ctx.asUser("user-1", () =>
|
||||
history.list(1, 10),
|
||||
);
|
||||
expect(listed.map((entry) => entry.command)).toEqual(["df -h"]);
|
||||
});
|
||||
|
||||
it("keeps a plugin caller to its own actor's sessions", async () => {
|
||||
server = await startServer();
|
||||
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
|
||||
const other = await server.mock.ctx.asUser("user-2", async () =>
|
||||
live.listForUser("user-1"),
|
||||
);
|
||||
expect(other).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the admin session timeout for detached sessions", async () => {
|
||||
server = await startServer({ settings: { sessionTimeoutMinutes: 90 } });
|
||||
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
|
||||
expect(live.idleTimeoutMinutes()).toBe(90);
|
||||
});
|
||||
|
||||
it("offers a host import normalizer", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
server.mock.ctx.registry.consume("ssh-terminal.hostImportNormalizer"),
|
||||
).toBe(hostImportNormalizer);
|
||||
});
|
||||
|
||||
it.each(["db:own", "network:serve"])(
|
||||
"fails closed without %s",
|
||||
async (capability) => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("host import", () => {
|
||||
it("carries the terminal switches of an imported host", () => {
|
||||
expect(hostImportNormalizer({ name: "web" })).toBeNull();
|
||||
expect(
|
||||
hostImportNormalizer({
|
||||
enableTerminal: false,
|
||||
enableCommandHistory: true,
|
||||
}),
|
||||
).toEqual({ enableTerminal: false, enableCommandHistory: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* the way core mounts it, with the acting user taken from a test header.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: { permissions?: string[]; settings?: Record<string, unknown> } = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions,
|
||||
settings: options.settings,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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 "../../../src/backend/images/image-storage-settings.js";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import { readImageStorageSettings } from "../../../src/backend/settings.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 the plugin's admin settings, and treats unset fields as unset", async () => {
|
||||
const { ctx } = createFakeContext({
|
||||
settings: {
|
||||
imageStorageMode: "local",
|
||||
imageLocalDir: "/persisted/images",
|
||||
},
|
||||
});
|
||||
const resolved = await readImageStorageSettings(ctx);
|
||||
expect(resolved.mode).toBe("local");
|
||||
expect(resolved.localDir).toBe(localDir("/persisted/images"));
|
||||
// Nothing stored and no environment: the built-in default.
|
||||
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
|
||||
});
|
||||
|
||||
it("keeps TERMINAL_IMAGE_STORAGE_KEYS as the lookup names", () => {
|
||||
expect(TERMINAL_IMAGE_STORAGE_KEYS.mode).toBe(
|
||||
"terminal_image_storage_mode",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
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 "../../../src/backend/images/image-storage.js";
|
||||
import type { TerminalImageStorageSettings } from "../../../src/backend/images/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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
exceedsNormalizedImageSize,
|
||||
imageExtensionForFormat,
|
||||
createConcurrencyLimiter,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "../../../src/backend/images/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
PluginKeyboardInteractiveDecision,
|
||||
PluginKeyboardInteractivePrompt,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { SSHAuthManager } from "../../src/backend/keyboard-prompt.js";
|
||||
|
||||
/**
|
||||
* Stands in for core's classifier (ctx.ssh.classifyKeyboardInteractive),
|
||||
* which core's own tests cover. These tests are about what the terminal does
|
||||
* with each decision: the socket messages, the answers and the timeouts.
|
||||
*/
|
||||
const PASSWORD = /password/i;
|
||||
const FORTI = /type\s+['"]?push['"]?/i;
|
||||
const PUSH =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
const TOTP =
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
|
||||
|
||||
function classify(
|
||||
round: {
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: PluginKeyboardInteractivePrompt[];
|
||||
},
|
||||
host: PluginSshHost,
|
||||
): PluginKeyboardInteractiveDecision {
|
||||
if (/warpgate/i.test(round.name)) {
|
||||
return {
|
||||
kind: "warpgate",
|
||||
url: round.instructions.match(/https?:\/\/\S+/)?.[0] ?? null,
|
||||
securityKey: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "",
|
||||
instructions: round.instructions,
|
||||
};
|
||||
}
|
||||
const texts = round.prompts.map((p) => p.prompt);
|
||||
const isPush =
|
||||
!texts.some((t) => FORTI.test(t)) && texts.some((t) => PUSH.test(t));
|
||||
if (!isPush) {
|
||||
const totp = round.prompts.findIndex((p) => TOTP.test(p.prompt));
|
||||
if (totp !== -1) return { kind: "totp", promptIndex: totp };
|
||||
}
|
||||
const stored = !!host.password && host.authType !== "none";
|
||||
const first = round.prompts.findIndex(
|
||||
(p) => !(PASSWORD.test(p.prompt) && stored),
|
||||
);
|
||||
if (first === -1) {
|
||||
return {
|
||||
kind: "auto",
|
||||
responses: round.prompts.map((p) =>
|
||||
PASSWORD.test(p.prompt) ? String(host.password) : "",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { kind: "input", promptIndex: first, isPush: PUSH.test(texts[first]) };
|
||||
}
|
||||
|
||||
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
function createManager() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
|
||||
const manager = new SSHAuthManager({
|
||||
ssh: { classifyKeyboardInteractive: classify },
|
||||
log,
|
||||
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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { generateKeyPairSync } from "crypto";
|
||||
import ssh2Pkg, { type ParsedKey } from "ssh2";
|
||||
import { MemoryAgent } from "../../src/backend/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("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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// command_history as core's SQLite bootstrap created it before 2.9.0, plus the
|
||||
// index performance-indexes.ts added.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE command_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_command_history_user_host ON command_history (user_id, host_id);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
const indexesOn = (table: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as { name: string }[]
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("adopting command_history", () => {
|
||||
it("keeps every existing row and its index when the legacy table is there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite.exec(`
|
||||
INSERT INTO command_history (user_id, host_id, command, executed_at)
|
||||
VALUES ('user-1', 1, 'ls -la', '2026-01-01T00:00:00.000Z'),
|
||||
('user-2', 2, 'uptime', '2026-01-02T00:00:00.000Z');
|
||||
`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_command_history"]);
|
||||
expect(tableExists("command_history")).toBe(false);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, host_id, command, executed_at FROM p_ssh_terminal_command_history ORDER BY id",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
host_id: 1,
|
||||
command: "ls -la",
|
||||
executed_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
user_id: "user-2",
|
||||
host_id: 2,
|
||||
command: "uptime",
|
||||
executed_at: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
|
||||
"idx_command_history_user_host",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a fresh table when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
expect(tableExists("p_ssh_terminal_command_history")).toBe(true);
|
||||
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
|
||||
"idx_command_history_user_host",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cascades when the user or the host is deleted", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
|
||||
},
|
||||
});
|
||||
db.sqlite.exec(`
|
||||
INSERT INTO p_ssh_terminal_command_history (user_id, host_id, command)
|
||||
VALUES ('user-1', 1, 'a'), ('user-2', 1, 'b'), ('user-2', 2, 'c');
|
||||
`);
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 2");
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT command FROM p_ssh_terminal_command_history")
|
||||
.all(),
|
||||
).toEqual([{ command: "b" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const history = (s: TestServer) =>
|
||||
s.db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, host_id, command FROM p_ssh_terminal_command_history ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
|
||||
describe("command history routes", () => {
|
||||
it("saves, lists newest first without duplicates, deletes and clears", async () => {
|
||||
server = await startServer();
|
||||
for (const command of ["ls", "pwd", "ls"]) {
|
||||
const saved = await server.request("POST", "/command-history", {
|
||||
body: { hostId: 1, command },
|
||||
});
|
||||
expect(saved.status).toBe(201);
|
||||
}
|
||||
await server.request("POST", "/command-history", {
|
||||
user: "user-2",
|
||||
body: { hostId: 1, command: "whoami" },
|
||||
});
|
||||
|
||||
const listed = await server.request("GET", "/command-history/1");
|
||||
expect(listed.body).toEqual(["ls", "pwd"]);
|
||||
|
||||
await server.request("POST", "/command-history/delete", {
|
||||
body: { hostId: 1, command: "pwd" },
|
||||
});
|
||||
expect((await server.request("GET", "/command-history/1")).body).toEqual([
|
||||
"ls",
|
||||
]);
|
||||
|
||||
await server.request("DELETE", "/command-history/1");
|
||||
expect(history(server)).toEqual([
|
||||
{ user_id: "user-2", host_id: 1, command: "whoami" },
|
||||
]);
|
||||
expect(server.db.persisted).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("acknowledges but never stores a command that looks like a secret", async () => {
|
||||
server = await startServer();
|
||||
const saved = await server.request("POST", "/command-history", {
|
||||
body: { hostId: 1, command: "export API_TOKEN=abc" },
|
||||
});
|
||||
expect(saved.status).toBe(201);
|
||||
expect(history(server)).toEqual([]);
|
||||
});
|
||||
|
||||
it("stores nothing while command history is off globally", async () => {
|
||||
server = await startServer({ settings: { commandHistoryEnabled: false } });
|
||||
await server.request("POST", "/command-history", {
|
||||
body: { hostId: 1, command: "ls" },
|
||||
});
|
||||
expect(history(server)).toEqual([]);
|
||||
});
|
||||
|
||||
it("stores nothing for a host that turned command history off", async () => {
|
||||
server = await startServer();
|
||||
await server.mock.ctx.settings.setHost(2, "enableCommandHistory", false);
|
||||
await server.request("POST", "/command-history", {
|
||||
body: { hostId: 2, command: "ls" },
|
||||
});
|
||||
await server.request("POST", "/command-history", {
|
||||
body: { hostId: 1, command: "ls" },
|
||||
});
|
||||
expect(history(server)).toEqual([
|
||||
{ user_id: "user-1", host_id: 1, command: "ls" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a request missing its fields", async () => {
|
||||
server = await startServer();
|
||||
const saved = await server.request("POST", "/command-history", {
|
||||
body: { hostId: 1 },
|
||||
});
|
||||
expect(saved.status).toBe(400);
|
||||
});
|
||||
|
||||
it("needs hosts.view for the recent list", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const denied = await server.request("GET", "/command-history/1/recent");
|
||||
expect(denied.status).toBe(403);
|
||||
});
|
||||
|
||||
it("lists recent commands with duplicates for a hosts.view holder", async () => {
|
||||
server = await startServer({ permissions: ["hosts.view"] });
|
||||
for (const command of ["uptime", "uptime"]) {
|
||||
await server.request("POST", "/command-history", {
|
||||
body: { hostId: 1, command },
|
||||
});
|
||||
}
|
||||
const allowed = await server.request("GET", "/command-history/1/recent");
|
||||
expect(allowed.body).toEqual(["uptime", "uptime"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client settings and image storage", () => {
|
||||
it("serves the terminal's settings to any signed-in user", async () => {
|
||||
server = await startServer({
|
||||
settings: { sessionTimeoutMinutes: 45, sessionPersistence: false },
|
||||
});
|
||||
const { body } = await server.request("GET", "/client-settings");
|
||||
expect(body.sessionTimeoutMinutes).toBe(45);
|
||||
expect(body.sessionPersistence).toBe(false);
|
||||
expect(body.commandHistoryEnabled).toBe(true);
|
||||
expect(body.touchInput.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses the image storage test to a non-admin", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const denied = await server.request("POST", "/image-storage/test", {
|
||||
body: { instanceId: "tab-1" },
|
||||
});
|
||||
expect(denied.status).toBe(403);
|
||||
});
|
||||
|
||||
it("reports an unconnected session to an admin", async () => {
|
||||
server = await startServer({ permissions: ["admin.plugins.manage"] });
|
||||
const result = await server.request("POST", "/image-storage/test", {
|
||||
body: { instanceId: "tab-1" },
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.connected).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
TerminalSessionManager,
|
||||
isMessageAllowedForParticipant,
|
||||
} from "../../src/backend/session-manager.js";
|
||||
import type { RecordingSink } from "../../src/backend/services.js";
|
||||
|
||||
// A recordings.writer provider that keeps what it was given.
|
||||
const appends = vi.fn<(chunk: string) => Promise<void>>();
|
||||
const persists = vi.fn<RecordingSink["persist"]>();
|
||||
const discards = vi.fn();
|
||||
const opens = vi.fn();
|
||||
|
||||
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const sessionManager = new TerminalSessionManager({
|
||||
log,
|
||||
getTimeoutMinutes: () => 30,
|
||||
getRecordings: () => ({
|
||||
open: async (meta) => {
|
||||
opens(meta);
|
||||
return { append: appends, persist: persists, discard: discards };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// 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();
|
||||
appends.mockResolvedValue(undefined);
|
||||
persists.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 record 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(opens).not.toHaveBeenCalled();
|
||||
expect(appends).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands the recording to the recordings service in one batch, then persists it", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
sessionManager.bufferOutput(id, "terminal output data");
|
||||
sessionManager.bufferOutput(id, "more output");
|
||||
sessionManager.bufferInput(id, "ls\r");
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
// One write for the whole burst, never one per chunk (issue #1049).
|
||||
expect(appends).toHaveBeenCalledOnce();
|
||||
const chunk = appends.mock.calls[0][0];
|
||||
expect(chunk.split("\n")[0]).toContain('"version":2');
|
||||
expect(chunk).toContain("terminal output data");
|
||||
expect(chunk).toContain("more output");
|
||||
expect(persists).toHaveBeenCalledOnce();
|
||||
expect(opens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ hostId: 1, userId: "u1", protocol: "ssh" }),
|
||||
);
|
||||
});
|
||||
|
||||
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(appends).not.toHaveBeenCalled();
|
||||
expect(discards).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("records nothing when no recordings service is running", async () => {
|
||||
const withoutRecordings = new TerminalSessionManager({
|
||||
log,
|
||||
getTimeoutMinutes: () => 30,
|
||||
getRecordings: () => null,
|
||||
});
|
||||
const id = withoutRecordings.createSession("u1", 1, "host", 80, 24);
|
||||
expect(withoutRecordings.getSession(id)?.sessionLoggingEnabled).toBe(false);
|
||||
withoutRecordings.bufferOutput(id, "output");
|
||||
withoutRecordings.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(appends).not.toHaveBeenCalled();
|
||||
withoutRecordings.destroyAll();
|
||||
});
|
||||
|
||||
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();
|
||||
appends.mockResolvedValue(undefined);
|
||||
persists.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user