mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
const shell = fs.readFileSync(
|
||||
new URL("../../../ui/AppShell.tsx", import.meta.url),
|
||||
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createC2SAuthBridge } = require("../../../../electron/c2s-auth.cjs");
|
||||
|
||||
function setup() {
|
||||
const sender = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
isDestroyed: () => false,
|
||||
});
|
||||
const ws = Object.assign(new EventEmitter(), {
|
||||
readyState: 1,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(() => {
|
||||
ws.readyState = 3;
|
||||
ws.emit("close");
|
||||
}),
|
||||
});
|
||||
let answer!: (
|
||||
event: { sender: unknown },
|
||||
id: string,
|
||||
value: unknown,
|
||||
) => boolean;
|
||||
const attach = createC2SAuthBridge(
|
||||
{
|
||||
handle: (_name: string, fn: typeof answer) => {
|
||||
answer = fn;
|
||||
},
|
||||
},
|
||||
() => ({ isDestroyed: () => false, webContents: sender }),
|
||||
);
|
||||
attach(ws, "dynamic");
|
||||
const prompt = () =>
|
||||
ws.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "auth-prompt",
|
||||
requestId: "server-challenge",
|
||||
request: { kind: "totp", prompt: "Code:", retry: false },
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
return { sender, ws, answer, prompt };
|
||||
}
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe("desktop tunnel prompt bridge", () => {
|
||||
it("binds answers to the renderer and challenge, and rejects replay", () => {
|
||||
const { sender, ws, answer, prompt } = setup();
|
||||
prompt();
|
||||
const id = sender.send.mock.calls[0][1].id;
|
||||
expect(answer({ sender: {} }, id, "123456")).toBe(false);
|
||||
expect(answer({ sender }, "unknown", "123456")).toBe(false);
|
||||
expect(answer({ sender }, id, { secret: "bad" })).toBe(false);
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
expect(answer({ sender }, id, "123456")).toBe(true);
|
||||
expect(JSON.parse(ws.send.mock.calls[0][0])).toEqual({
|
||||
type: "auth-response",
|
||||
requestId: "server-challenge",
|
||||
answer: "123456",
|
||||
});
|
||||
expect(answer({ sender }, id, "again")).toBe(false);
|
||||
expect(sender.listenerCount("destroyed")).toBe(0);
|
||||
ws.close();
|
||||
});
|
||||
it.each(["close", "reload", "timeout"])(
|
||||
"clears a pending dialog on %s",
|
||||
(reason) => {
|
||||
vi.useFakeTimers();
|
||||
const { sender, ws, answer, prompt } = setup();
|
||||
prompt();
|
||||
const id = sender.send.mock.calls[0][1].id;
|
||||
if (reason === "close") ws.close();
|
||||
else if (reason === "reload") sender.emit("did-start-loading");
|
||||
else vi.advanceTimersByTime(60_000);
|
||||
expect(sender.send).toHaveBeenLastCalledWith("c2s-auth-prompt", {
|
||||
id,
|
||||
closed: true,
|
||||
});
|
||||
expect(answer({ sender }, id, "late")).toBe(false);
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const main = readFileSync(
|
||||
new URL("../../../../electron/main.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const section = (from: string, to: string) =>
|
||||
main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from)));
|
||||
function setup() {
|
||||
const sockets: Socket[] = [];
|
||||
class Socket extends EventEmitter {
|
||||
readyState = 1;
|
||||
send = vi.fn();
|
||||
close = vi.fn(() => {
|
||||
if (this.readyState === 3) return;
|
||||
this.readyState = 3;
|
||||
this.emit("close");
|
||||
});
|
||||
constructor() {
|
||||
super();
|
||||
sockets.push(this);
|
||||
queueMicrotask(() => this.emit("open"));
|
||||
}
|
||||
ready() {
|
||||
this.emit(
|
||||
"message",
|
||||
Buffer.from(JSON.stringify({ type: "ready" })),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
const runtimes = new Map();
|
||||
let incoming!: (socket: EventEmitter) => void;
|
||||
const server = Object.assign(new EventEmitter(), {
|
||||
listen: (_options: unknown, ready: () => void) => ready(),
|
||||
close: vi.fn((done?: () => void) => done?.()),
|
||||
});
|
||||
const headers = vi.fn(async () => ({}));
|
||||
const dynamic = vi.fn();
|
||||
const context = vm.createContext({
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
WebSocket: Socket,
|
||||
crypto: { randomUUID: () => "runtime-token" },
|
||||
net: {
|
||||
createServer: (callback: typeof incoming) => {
|
||||
incoming = callback;
|
||||
return server;
|
||||
},
|
||||
},
|
||||
c2sTunnelRuntimes: runtimes,
|
||||
normalizeC2STunnelAddresses: (t: unknown) => t,
|
||||
resolveC2SRemoteSourceHost: async (t: unknown) => t,
|
||||
getC2STunnelName: (t: { name: string }) => t.name,
|
||||
getC2SLocalAddress: () => "127.0.0.1",
|
||||
getC2SRemoteAddress: () => "127.0.0.1",
|
||||
getC2SRelayUrl: () => "wss://server/relay",
|
||||
getC2SRelayHeaders: headers,
|
||||
getWebSocketOptions: (_url: string, options: unknown) => options,
|
||||
attachC2SAuth: vi.fn(),
|
||||
keepC2SSocketAlive: vi.fn(),
|
||||
logToFile: vi.fn(),
|
||||
emitC2STunnelStatuses: vi.fn(),
|
||||
checkLocalPortAvailable: async () => ({ available: true }),
|
||||
handleC2SDynamicConnection: dynamic,
|
||||
setC2STunnelStatus: (name: string, status: unknown) => {
|
||||
runtimes.get(name).status = status;
|
||||
},
|
||||
setC2STunnelError: (name: string, error: string) => {
|
||||
runtimes.get(name).status = { connected: false, reason: error };
|
||||
},
|
||||
createC2SFailure: (error: string) => ({ success: false, error }),
|
||||
normalizeC2SErrorMessage: (message: string) => message,
|
||||
C2S_REMOTE_SESSION_EXPIRED_ERROR: "expired",
|
||||
});
|
||||
vm.runInContext(
|
||||
section("async function openC2SRelay(", "async function testC2SRelay(") +
|
||||
section("async function testC2SRelay(", "async function testC2STunnel(") +
|
||||
section("async function startC2STunnel(", "function stopAllC2STunnels("),
|
||||
context,
|
||||
);
|
||||
return {
|
||||
context,
|
||||
runtimes,
|
||||
sockets,
|
||||
server,
|
||||
headers,
|
||||
dynamic,
|
||||
incoming: () => incoming,
|
||||
};
|
||||
}
|
||||
|
||||
describe("client tunnel runtime authentication", () => {
|
||||
it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => {
|
||||
const { context, runtimes, sockets, server, headers, dynamic, incoming } =
|
||||
setup();
|
||||
const tunnel = {
|
||||
name: "socks",
|
||||
mode: "dynamic",
|
||||
sourceHostId: 7,
|
||||
sourcePort: 1080,
|
||||
};
|
||||
await context.startC2STunnel(tunnel, 0, "local-auth");
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
||||
const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() });
|
||||
incoming()(premature);
|
||||
expect(premature.destroy).toHaveBeenCalledOnce();
|
||||
expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth");
|
||||
expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({
|
||||
keepAlive: true,
|
||||
tunnelConfig: { sessionId: "runtime-token" },
|
||||
});
|
||||
sockets[0].ready();
|
||||
await vi.waitFor(() =>
|
||||
expect(runtimes.get("socks").status.connected).toBe(true),
|
||||
);
|
||||
expect(sockets[0].close).not.toHaveBeenCalled();
|
||||
const client = Object.assign(new EventEmitter(), { destroy: vi.fn() });
|
||||
incoming()(client);
|
||||
expect(dynamic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: "runtime-token" }),
|
||||
client,
|
||||
"local-auth",
|
||||
);
|
||||
await context.stopC2STunnel("socks");
|
||||
expect(sockets[0].close).toHaveBeenCalledOnce();
|
||||
expect(client.destroy).toHaveBeenCalledOnce();
|
||||
expect(server.close).toHaveBeenCalledOnce();
|
||||
expect(runtimes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not retain a socket for a standalone test or a stopped runtime", async () => {
|
||||
const { context, sockets } = setup();
|
||||
const test = context.testC2SRelay({ name: "test" });
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
||||
sockets[0].ready();
|
||||
expect(await test).toEqual({ success: true });
|
||||
expect(sockets[0].close).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
await context.testC2SRelay({ name: "stopped" }, null, null, null, {}),
|
||||
).toEqual({ success: false, error: "Tunnel stopped" });
|
||||
expect(sockets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the relay's reason when it closes after an error", async () => {
|
||||
const { context, runtimes, sockets } = setup();
|
||||
const runtime = { status: { connected: true } };
|
||||
runtimes.set("web", runtime);
|
||||
const test = context.testC2SRelay(
|
||||
{ name: "web" },
|
||||
"127.0.0.1",
|
||||
8443,
|
||||
null,
|
||||
runtime,
|
||||
);
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
||||
sockets[0].emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "Target 127.0.0.1:8443 refused",
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
sockets[0].close();
|
||||
expect(await test).toMatchObject({ success: false });
|
||||
expect(runtimes.get("web").status.reason).toBe(
|
||||
"Target 127.0.0.1:8443 refused",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the tunnel connected when one of its connections fails", async () => {
|
||||
const { context, runtimes, sockets } = setup();
|
||||
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
|
||||
const local = () =>
|
||||
Object.assign(new EventEmitter(), {
|
||||
destroy: vi.fn(),
|
||||
write: vi.fn(),
|
||||
});
|
||||
|
||||
const first = local();
|
||||
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, first);
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
||||
first.emit("error", new Error("ECONNRESET"));
|
||||
sockets[0].emit(
|
||||
"message",
|
||||
Buffer.from(JSON.stringify({ type: "error", error: "refused" })),
|
||||
false,
|
||||
);
|
||||
expect(runtimes.get("web").status.connected).toBe(true);
|
||||
|
||||
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, local());
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(2));
|
||||
sockets[1].emit(
|
||||
"message",
|
||||
Buffer.from(JSON.stringify({ type: "error", error: "expired" })),
|
||||
false,
|
||||
);
|
||||
expect(runtimes.get("web").status).toMatchObject({
|
||||
connected: false,
|
||||
reason: "expired",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("client tunnel socket keepalive", () => {
|
||||
it("pings an open socket until it closes", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const context = vm.createContext({
|
||||
setInterval,
|
||||
clearInterval,
|
||||
WebSocket: { OPEN: 1 },
|
||||
});
|
||||
vm.runInContext(
|
||||
section("function keepC2SSocketAlive(", "// C2S (client-to-server)"),
|
||||
context,
|
||||
);
|
||||
const ws = Object.assign(new EventEmitter(), {
|
||||
readyState: 1,
|
||||
ping: vi.fn(),
|
||||
});
|
||||
context.keepC2SSocketAlive(ws);
|
||||
vi.advanceTimersByTime(50_000);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||
ws.emit("close");
|
||||
vi.advanceTimersByTime(50_000);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createEmbeddedViews, cleanBounds } = createRequire(import.meta.url)(
|
||||
"../../../../electron/embedded-view.cjs",
|
||||
);
|
||||
|
||||
const views: FakeView[] = [];
|
||||
|
||||
class FakeView {
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
loadURL: vi.fn().mockResolvedValue(undefined),
|
||||
isDestroyed: () => false,
|
||||
close: vi.fn(),
|
||||
});
|
||||
setBounds = vi.fn();
|
||||
setVisible = vi.fn();
|
||||
constructor(public options: { webPreferences: Record<string, unknown> }) {
|
||||
views.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = { x: 10.4, y: 20.6, width: 300, height: 200 };
|
||||
let contentView: {
|
||||
addChildView: ReturnType<typeof vi.fn>;
|
||||
removeChildView: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let session: EventEmitter & Record<string, ReturnType<typeof vi.fn>>;
|
||||
let manager: ReturnType<typeof createEmbeddedViews>;
|
||||
|
||||
beforeEach(() => {
|
||||
views.length = 0;
|
||||
contentView = { addChildView: vi.fn(), removeChildView: vi.fn() };
|
||||
session = Object.assign(new EventEmitter(), {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
closeAllConnections: vi.fn().mockResolvedValue(undefined),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
});
|
||||
manager = createEmbeddedViews({
|
||||
WebContentsView: FakeView,
|
||||
session: { fromPartition: vi.fn(() => session) },
|
||||
getMainWindow: () => ({ contentView, isDestroyed: () => false }),
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded views", () => {
|
||||
it("opens a locked down view at the given bounds", () => {
|
||||
manager.open({ id: "a", url: "https://pve.example:8006/", bounds });
|
||||
const [view] = views;
|
||||
expect(view.options.webPreferences).toMatchObject({
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
});
|
||||
expect(contentView.addChildView).toHaveBeenCalledWith(view);
|
||||
expect(view.setBounds).toHaveBeenCalledWith({
|
||||
x: 10,
|
||||
y: 21,
|
||||
width: 300,
|
||||
height: 200,
|
||||
});
|
||||
expect(view.webContents.loadURL).toHaveBeenCalledWith(
|
||||
"https://pve.example:8006/",
|
||||
);
|
||||
const handler = session.setPermissionRequestHandler.mock.calls[0][0];
|
||||
const allow = vi.fn();
|
||||
handler(null, "media", allow);
|
||||
expect(allow).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("refuses urls that are not plain http or https", () => {
|
||||
expect(() =>
|
||||
manager.open({ id: "a", url: "file:///etc/passwd", bounds }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
manager.open({ id: "a", url: "https://u:p@host/", bounds }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("moves and hides an open view, and closes it", () => {
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
manager.setBounds("a", { x: 1, y: 2, width: 3, height: 4 });
|
||||
manager.setVisible("a", false);
|
||||
const [view] = views;
|
||||
expect(view.setBounds).toHaveBeenLastCalledWith({
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
});
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false);
|
||||
manager.destroy("a");
|
||||
expect(contentView.removeChildView).toHaveBeenCalledWith(view);
|
||||
expect(view.webContents.close).toHaveBeenCalled();
|
||||
expect(session.clearStorageData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses a view for the same url and replaces it for a new one", () => {
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
expect(views).toHaveLength(1);
|
||||
manager.open({ id: "a", url: "https://y.example/", bounds });
|
||||
expect(views).toHaveLength(2);
|
||||
expect(contentView.removeChildView).toHaveBeenCalledWith(views[0]);
|
||||
});
|
||||
|
||||
it("accepts an invalid certificate only for its own origin and only when asked", () => {
|
||||
manager.open({
|
||||
id: "a",
|
||||
url: "https://x.example/",
|
||||
ignoreCert: true,
|
||||
bounds,
|
||||
});
|
||||
manager.open({ id: "b", url: "https://z.example/", bounds });
|
||||
const check = (index: number, url: string) => {
|
||||
const event = { preventDefault: vi.fn() };
|
||||
const callback = vi.fn();
|
||||
const handled = manager.handleCertificateError(
|
||||
event,
|
||||
views[index].webContents,
|
||||
url,
|
||||
callback,
|
||||
);
|
||||
return { handled, allowed: callback.mock.calls[0]?.[0] };
|
||||
};
|
||||
expect(check(0, "https://x.example/page")).toEqual({
|
||||
handled: true,
|
||||
allowed: true,
|
||||
});
|
||||
expect(check(0, "https://other.example/")).toEqual({
|
||||
handled: true,
|
||||
allowed: false,
|
||||
});
|
||||
expect(check(1, "https://z.example/")).toEqual({
|
||||
handled: true,
|
||||
allowed: false,
|
||||
});
|
||||
expect(
|
||||
manager.handleCertificateError({}, {}, "https://x.example/", vi.fn()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("closes everything", () => {
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
manager.open({ id: "b", url: "https://y.example/", bounds });
|
||||
manager.destroyAll();
|
||||
expect(contentView.removeChildView).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps bounds as whole, non negative numbers", () => {
|
||||
expect(cleanBounds({ x: -5, y: "x", width: 9.5, height: 1 })).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
||||
const require = createRequire(import.meta.url);
|
||||
const linkedServer = require("../../../../electron/linked-server.cjs") as {
|
||||
setLinkedServer: (config: unknown) => void;
|
||||
clientCertOptionsFor: (url: string) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
const source = fs.readFileSync(
|
||||
@@ -14,7 +15,7 @@ const source = fs.readFileSync(
|
||||
);
|
||||
const tls = source.slice(
|
||||
source.indexOf("const INSECURE_MODE_VALUES"),
|
||||
source.indexOf("function httpFetch("),
|
||||
source.indexOf('if (process.platform === "linux") {'),
|
||||
);
|
||||
|
||||
const ORIGIN = "https://termix.example";
|
||||
@@ -30,10 +31,7 @@ function load(link: Record<string, unknown> | null) {
|
||||
vm.runInContext(tls, context);
|
||||
return context as unknown as {
|
||||
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
|
||||
getWebSocketOptions: (
|
||||
url: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
getTlsVerificationOptions: (url: string) => Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +39,7 @@ describe("linked server certificate exception", () => {
|
||||
it("covers WebSockets to the linked server", () => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
|
||||
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
|
||||
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
|
||||
headers: {},
|
||||
expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
});
|
||||
@@ -58,8 +55,31 @@ describe("linked server certificate exception", () => {
|
||||
|
||||
it("keeps verifying when the link does not allow it", () => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
|
||||
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
|
||||
expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("linked server client certificate", () => {
|
||||
const clientCert = {
|
||||
pfx: Buffer.from("pfx-bytes").toString("base64"),
|
||||
passphrase: "pw",
|
||||
};
|
||||
|
||||
it("hands the PFX to TLS made for the linked origin only", () => {
|
||||
linkedServer.setLinkedServer({ origin: ORIGIN, clientCert });
|
||||
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({
|
||||
pfx: Buffer.from("pfx-bytes"),
|
||||
passphrase: "pw",
|
||||
});
|
||||
expect(
|
||||
linkedServer.clientCertOptionsFor("https://elsewhere.example/"),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("offers nothing when the link has no certificate", () => {
|
||||
linkedServer.setLinkedServer({ origin: ORIGIN });
|
||||
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = fs.readFileSync(
|
||||
new URL("../../../../electron/main.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const collector = source.slice(
|
||||
source.indexOf("function collectLocalFilesFromPath("),
|
||||
source.indexOf('ipcMain.handle("get-local-home-directory"'),
|
||||
);
|
||||
const handler = source.slice(
|
||||
source.indexOf('ipcMain.handle("collect-local-files"'),
|
||||
source.indexOf('ipcMain.handle("create-local-folder"'),
|
||||
);
|
||||
|
||||
describe("local collection limit", () => {
|
||||
it.each([9999, 10000, 10001])(
|
||||
"reports truncation only when %s files exceed the limit",
|
||||
(count) => {
|
||||
let collect: (
|
||||
_event: unknown,
|
||||
paths: string[],
|
||||
) => { success: boolean; files: unknown[]; truncated: boolean };
|
||||
vm.runInNewContext(collector + handler, {
|
||||
fs: {
|
||||
lstatSync: (p: string) => ({
|
||||
isSymbolicLink: () => false,
|
||||
isFile: () => p !== "/root",
|
||||
isDirectory: () => p === "/root",
|
||||
size: 1,
|
||||
birthtime: new Date(0),
|
||||
mtime: new Date(0),
|
||||
}),
|
||||
readdirSync: () =>
|
||||
Array.from({ length: count }, (_, i) => `file${i}`),
|
||||
},
|
||||
path: path.posix,
|
||||
normalizeRelativeLocalPath: (p: string) => p,
|
||||
ipcMain: {
|
||||
handle: (_name: string, callback: typeof collect) => {
|
||||
collect = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = collect!(null, ["/root"]);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.files).toHaveLength(Math.min(10000, count));
|
||||
expect(result.truncated).toBe(count > 10000);
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,99 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { resolveLocalShell, repairSpawnHelpers } =
|
||||
require("../../../../electron/local-shell.cjs") as {
|
||||
resolveLocalShell: (
|
||||
platform: NodeJS.Platform,
|
||||
requestedShell?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
exists?: (file: string) => boolean,
|
||||
) => { file: string; args: string[] };
|
||||
repairSpawnHelpers: (
|
||||
ptyDir: string,
|
||||
fsImpl: {
|
||||
statSync: (file: string) => { mode: number };
|
||||
chmodSync: (file: string, mode: number) => void;
|
||||
},
|
||||
) => number;
|
||||
};
|
||||
|
||||
describe("resolveLocalShell", () => {
|
||||
it("starts the default WSL distribution without PowerShell arguments", () => {
|
||||
expect(resolveLocalShell("win32", "wsl", {})).toEqual({
|
||||
file: "wsl.exe",
|
||||
args: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps PowerShell as the default Windows shell", () => {
|
||||
expect(resolveLocalShell("win32", "default", {})).toEqual({
|
||||
file: "powershell.exe",
|
||||
args: ["-NoLogo"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the configured shell on non-Windows platforms", () => {
|
||||
expect(
|
||||
resolveLocalShell("linux", "wsl", { SHELL: "/bin/fish" }, () => true),
|
||||
).toEqual({
|
||||
file: "/bin/fish",
|
||||
args: ["-l"],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a configured shell that is not installed", () => {
|
||||
const installed = new Set(["/bin/bash", "/bin/zsh"]);
|
||||
expect(
|
||||
resolveLocalShell(
|
||||
"darwin",
|
||||
undefined,
|
||||
{ SHELL: "/opt/homebrew/bin/fish" },
|
||||
(file) => installed.has(file),
|
||||
).file,
|
||||
).toBe("/bin/zsh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairSpawnHelpers", () => {
|
||||
it("restores the execute bit in the unpacked copy only when it is missing", () => {
|
||||
const modes = new Map<string, number>();
|
||||
const chmods: Array<[string, number]> = [];
|
||||
const fsImpl = {
|
||||
statSync: (file: string) => {
|
||||
const mode = modes.get(file);
|
||||
if (mode === undefined) throw new Error("ENOENT");
|
||||
return { mode };
|
||||
},
|
||||
chmodSync: (file: string, mode: number) => {
|
||||
chmods.push([file, mode]);
|
||||
},
|
||||
};
|
||||
const dir =
|
||||
"/Applications/Termix.app/Contents/Resources/app.asar/node_modules/node-pty";
|
||||
const unpacked = dir.replace("app.asar", "app.asar.unpacked");
|
||||
const release = `${unpacked}/build/Release/spawn-helper`;
|
||||
modes.set(release.split("/").join(require("node:path").sep), 0o644);
|
||||
modes.set(release, 0o644);
|
||||
expect(repairSpawnHelpers(dir, fsImpl)).toBe(1);
|
||||
expect(chmods[0][1] & 0o111).not.toBe(0);
|
||||
expect(chmods[0][0]).toContain("app.asar.unpacked");
|
||||
});
|
||||
|
||||
it.each(["app-arm64.asar", "app-x64.asar"])(
|
||||
"repairs the helper in the unpacked copy of a universal build's %s",
|
||||
(asar) => {
|
||||
const chmods: string[] = [];
|
||||
const fsImpl = {
|
||||
statSync: () => ({ mode: 0o644 }),
|
||||
chmodSync: (file: string) => {
|
||||
chmods.push(file.split(require("node:path").sep).join("/"));
|
||||
},
|
||||
};
|
||||
const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`;
|
||||
repairSpawnHelpers(dir, fsImpl);
|
||||
expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildRdpFile, validateNativeRdpOptions } =
|
||||
require("../../../../electron/native-rdp.cjs") as {
|
||||
buildRdpFile: (options: Record<string, unknown>) => string;
|
||||
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
describe("native Windows RDP", () => {
|
||||
it("creates a credential-prompting RDP file without a password", () => {
|
||||
const result = buildRdpFile({
|
||||
host: "server.example.com",
|
||||
port: 3390,
|
||||
username: "alice",
|
||||
domain: "EXAMPLE",
|
||||
password: "must-not-be-written",
|
||||
});
|
||||
|
||||
expect(result).toContain("full address:s:server.example.com:3390");
|
||||
expect(result).toContain("username:s:EXAMPLE\\alice");
|
||||
expect(result).toContain("prompt for credentials:i:1");
|
||||
expect(result).not.toContain("must-not-be-written");
|
||||
});
|
||||
|
||||
it("rejects unsafe host and port values", () => {
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
|
||||
).not.toThrow();
|
||||
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "server", port: 70000 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
const { renameLocalPath } = createRequire(import.meta.url)(
|
||||
"../../../../electron/rename-local-path.cjs",
|
||||
);
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-rename-"));
|
||||
});
|
||||
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
describe("local rename", () => {
|
||||
it("preserves both files on a name collision", () => {
|
||||
const a = path.join(root, "a"),
|
||||
b = path.join(root, "b");
|
||||
fs.writeFileSync(a, "source");
|
||||
fs.writeFileSync(b, "destination");
|
||||
expect(() => renameLocalPath(a, b)).toThrow(/already exists/);
|
||||
expect(fs.readFileSync(a, "utf8")).toBe("source");
|
||||
expect(fs.readFileSync(b, "utf8")).toBe("destination");
|
||||
});
|
||||
it("refuses to replace even an empty destination directory", () => {
|
||||
const a = path.join(root, "a"),
|
||||
b = path.join(root, "b");
|
||||
fs.mkdirSync(a);
|
||||
fs.mkdirSync(b);
|
||||
expect(() => renameLocalPath(a, b)).toThrow(/already exists/);
|
||||
expect(fs.readdirSync(root).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
it("renames a file and handles an unchanged name", () => {
|
||||
const a = path.join(root, "a"),
|
||||
b = path.join(root, "b");
|
||||
fs.writeFileSync(a, "source");
|
||||
renameLocalPath(a, a);
|
||||
renameLocalPath(a, b);
|
||||
expect(fs.existsSync(a)).toBe(false);
|
||||
expect(fs.readFileSync(b, "utf8")).toBe("source");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user