mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withDriveSettings } from "../../src/backend/drive-settings.js";
|
||||
|
||||
describe("withDriveSettings", () => {
|
||||
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
|
||||
expect(
|
||||
withDriveSettings({ "enable-drive": true }, "user-1", {
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
|
||||
}),
|
||||
).toEqual({
|
||||
"enable-drive": true,
|
||||
"drive-path": "/termix-data/rdp-drive/user-1",
|
||||
"create-drive-path": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to /drive when the environment says nothing", () => {
|
||||
expect(
|
||||
withDriveSettings({ "enable-drive": true }, "user-1", {}),
|
||||
).toMatchObject({ "drive-path": "/drive/user-1" });
|
||||
});
|
||||
|
||||
it("leaves a host-chosen drive-path alone", () => {
|
||||
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
|
||||
expect(
|
||||
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
|
||||
).toBe(config);
|
||||
});
|
||||
|
||||
it("does nothing when the drive is not enabled", () => {
|
||||
const config = { "enable-drive": false };
|
||||
expect(withDriveSettings(config, "user-1")).toBe(config);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveJumpTunnelEndpoint } from "../../src/backend/jump-tunnel-endpoint.js";
|
||||
|
||||
describe("resolveJumpTunnelEndpoint", () => {
|
||||
it("keeps an in-process guacd tunnel on loopback", () => {
|
||||
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
|
||||
bindHost: "127.0.0.1",
|
||||
advertisedHost: "127.0.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the tunnel to a separate guacd container", () => {
|
||||
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports a custom backend hostname for external guacd", () => {
|
||||
expect(
|
||||
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
|
||||
).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix-backend",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import net from "net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createMacosVncCompatibilityProxy } from "../../src/backend/macos-vnc-proxy.js";
|
||||
|
||||
const closers: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const close of closers.splice(0)) close();
|
||||
});
|
||||
|
||||
async function listen(server: net.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve((server.address() as net.AddressInfo).port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function read(socket: net.Socket, length: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let received = Buffer.alloc(0);
|
||||
const onData = (chunk: Buffer) => {
|
||||
received = Buffer.concat([received, chunk]);
|
||||
if (received.length < length) return;
|
||||
socket.off("data", onData);
|
||||
resolve(received);
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("createMacosVncCompatibilityProxy", () => {
|
||||
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
|
||||
let clientBanner = "";
|
||||
let selectedSecurityType = 0;
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.");
|
||||
socket.write("889\n");
|
||||
socket.once("data", (data) => {
|
||||
clientBanner = data.toString("ascii");
|
||||
socket.write(Buffer.from([5, 30, 33]));
|
||||
socket.write(Buffer.from([36, 2, 35]));
|
||||
socket.once("data", (selection) => {
|
||||
selectedSecurityType = selection[0];
|
||||
socket.write("desktop-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
|
||||
"RFB 003.008\n",
|
||||
);
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 2))]).toEqual([1, 2]);
|
||||
client.write(Buffer.from([2]));
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
|
||||
expect(clientBanner).toBe("RFB 003.008\n");
|
||||
expect(selectedSecurityType).toBe(2);
|
||||
});
|
||||
|
||||
it("passes standard RFB negotiation through unchanged", async () => {
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.008\n");
|
||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
|
||||
});
|
||||
|
||||
it("passes Apple's security types through when VNC auth is unavailable", async () => {
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.889\n");
|
||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
resolveRdpAuthTypeForConnect,
|
||||
resolveRdpDomain,
|
||||
} from "../../src/backend/rdp-settings.js";
|
||||
|
||||
describe("buildRdpSettings", () => {
|
||||
it("keeps saved RDP settings authoritative over stale advanced config", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
ignoreCert: true,
|
||||
guacConfig: {
|
||||
port: 3389,
|
||||
domain: "OLD",
|
||||
security: "rdp",
|
||||
"ignore-cert": false,
|
||||
"color-depth": 24,
|
||||
},
|
||||
guacdOverrides: { guacdHost: "guacd.internal" },
|
||||
}),
|
||||
).toEqual({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
"ignore-cert": true,
|
||||
"color-depth": 24,
|
||||
guacdHost: "guacd.internal",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an advanced security value when no saved value exists", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3389,
|
||||
ignoreCert: false,
|
||||
guacConfig: { security: "tls" },
|
||||
guacdOverrides: {},
|
||||
}).security,
|
||||
).toBe("tls");
|
||||
});
|
||||
|
||||
it("uses the prompted domain for prompt-on-connect authentication", () => {
|
||||
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
|
||||
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the stored domain for saved authentication", () => {
|
||||
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
|
||||
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRdpAuthTypeForConnect", () => {
|
||||
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "secretless" },
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("uses a recipient override instead of prompting", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "personal-override" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
|
||||
it("uses authentication shared by the owner", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "credential",
|
||||
sharedResolution: { source: "owner-shared", authType: "credential" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withRecordingSettings } from "../../src/backend/recording-settings.js";
|
||||
|
||||
const PATH = "/app/data/session_recordings/guacamole";
|
||||
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
|
||||
|
||||
describe("withRecordingSettings", () => {
|
||||
it("takes ownership of the location and filename", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-path": "/var/lib/termix/recordings",
|
||||
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
|
||||
"create-recording-path": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-path": PATH,
|
||||
"recording-name": NAME,
|
||||
"create-recording-path": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the content flags when the host has no opinion", () => {
|
||||
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
|
||||
"recording-exclude-output": false,
|
||||
"recording-include-keys": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the host's content flags, including the falsy ones", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrelated settings alone", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{ "recording-exclude-mouse": true, width: "1920" },
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-mouse": true,
|
||||
width: "1920",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the settings it was given", () => {
|
||||
const original = { "recording-path": "/tmp/mine" };
|
||||
withRecordingSettings(original, PATH, NAME);
|
||||
|
||||
expect(original).toEqual({ "recording-path": "/tmp/mine" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
it("returns the minted token without waiting for a connection that needs that token", async () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../src/backend/routes.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"routes.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const route = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isCallExpression(s.expression) &&
|
||||
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
|
||||
) as ts.ExpressionStatement;
|
||||
const callback = (route.expression as ts.CallExpression)
|
||||
.arguments[1] as ts.ArrowFunction;
|
||||
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
|
||||
const index = block.tryBlock.statements
|
||||
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
.at(-1)!;
|
||||
expect(index).toBeGreaterThan(-1);
|
||||
const tail = block.tryBlock.statements
|
||||
.slice(index + 1)
|
||||
.map((s) => s.getText(ast))
|
||||
.join("\n");
|
||||
const json = vi.fn();
|
||||
const wait = vi.fn(() => {
|
||||
throw new Error("Client has not received the token yet");
|
||||
});
|
||||
await runInNewContext(
|
||||
ts.transpileModule(`(async () => { ${tail} })()`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
waitForGuacdOpen: wait,
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
req: {},
|
||||
getRequestMeta: () => ({}),
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => "owner",
|
||||
userId: "owner",
|
||||
connectionType: "rdp",
|
||||
hostId: 7,
|
||||
hostname: "host",
|
||||
port: 3389,
|
||||
res: { json },
|
||||
},
|
||||
);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
guacamoleConnectionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("only reveals the live session ID to its owner", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../src/backend/guacamole-server.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"server.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const fn = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isFunctionDeclaration(s) &&
|
||||
s.name?.text === "getGuacSessionByConnectId",
|
||||
)!;
|
||||
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
|
||||
const context = {
|
||||
exports: {} as Record<string, (id: string, user: string) => unknown>,
|
||||
guacSessionByConnectId: new Map([["connect-id", info]]),
|
||||
};
|
||||
runInNewContext(
|
||||
ts.transpileModule(fn.getText(ast), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
const lookup = context.exports.getGuacSessionByConnectId;
|
||||
expect(lookup("connect-id", "owner")).toEqual(info);
|
||||
expect(lookup("connect-id", "other")).toBeNull();
|
||||
expect(lookup("pending", "owner")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
||||
guacLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { GuacamoleTokenService } =
|
||||
await import("../../src/backend/token-service.js");
|
||||
|
||||
describe("GuacamoleTokenService", () => {
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
it("disables RDP pre-authentication when no credentials are configured", () => {
|
||||
const token = tokenService.createRdpToken("windows.example.test", "", "");
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
"disable-auth": true,
|
||||
});
|
||||
expect(decrypted?.connection.settings.username).toBeUndefined();
|
||||
expect(decrypted?.connection.settings.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps normal RDP credential authentication unchanged", () => {
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
username: "Administrator",
|
||||
password: "secret",
|
||||
port: 3389,
|
||||
});
|
||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves recording metadata outside guacd connection settings", () => {
|
||||
const recording = {
|
||||
hostId: 42,
|
||||
userId: "user-1",
|
||||
protocol: "vnc" as const,
|
||||
path: "recording.guac",
|
||||
startedAt: "2026-07-14T00:00:00.000Z",
|
||||
};
|
||||
const token = tokenService.createVncToken(
|
||||
"vnc.example.test",
|
||||
"user",
|
||||
"secret",
|
||||
{},
|
||||
recording,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||
});
|
||||
|
||||
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
||||
const termixMeta = {
|
||||
termixConnectId: "connect-1",
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
};
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
{},
|
||||
undefined,
|
||||
termixMeta,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
||||
});
|
||||
|
||||
it("createJoinToken sets connection.join, not connection.type", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
||||
expect(decrypted?.connection.type).toBeUndefined();
|
||||
expect(decrypted?.connection.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
||||
expect(decrypted?.connection.readOnly).toBe(false);
|
||||
expect(decrypted?.recording).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GuacamoleToolbar } from "../../src/frontend/GuacamoleToolbar.js";
|
||||
import type { GuacamoleDisplayHandle } from "../../src/frontend/GuacamoleDisplay.js";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("GuacamoleToolbar Windows key", () => {
|
||||
it("sends Super_L for Win shortcuts and the sticky modifier", () => {
|
||||
const sendKey = vi.fn();
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey,
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: async () => {},
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
const { getByText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="rdp"
|
||||
metaKeyFamily="win"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText("Win+L"));
|
||||
expect(sendKey.mock.calls).toEqual([
|
||||
[0xffeb, true],
|
||||
[0x006c, true],
|
||||
[0x006c, false],
|
||||
[0xffeb, false],
|
||||
]);
|
||||
|
||||
sendKey.mockClear();
|
||||
fireEvent.click(getByText("Win"));
|
||||
expect(sendKey.mock.calls).toEqual([
|
||||
[0xffeb, true],
|
||||
[0xffeb, false],
|
||||
]);
|
||||
|
||||
sendKey.mockClear();
|
||||
fireEvent.click(getByText("remoteDesktop.toolbar.win"));
|
||||
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
|
||||
});
|
||||
|
||||
it("exposes VNC zoom controls without showing them for RDP", () => {
|
||||
const zoomIn = vi.fn(() => 1.25);
|
||||
const zoomOut = vi.fn(() => 0.75);
|
||||
const resetZoom = vi.fn(() => 1);
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: async () => {},
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
const { getByLabelText, getByText } = render(
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomOut"));
|
||||
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomIn"));
|
||||
fireEvent.click(getByText("125%"));
|
||||
expect(zoomOut).toHaveBeenCalledOnce();
|
||||
expect(zoomIn).toHaveBeenCalledOnce();
|
||||
expect(resetZoom).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers an explicit session-only hide action", () => {
|
||||
const onHide = vi.fn();
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: vi.fn(),
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
|
||||
const { getByLabelText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="rdp"
|
||||
onHide={onHide}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("remoteDesktop.toolbar.hide"));
|
||||
expect(onHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("labels the VNC meta key Super instead of Windows", () => {
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: vi.fn(),
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
|
||||
const { getByText, queryByText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="vnc"
|
||||
metaKeyFamily="super"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText("Super+L")).toBeTruthy();
|
||||
expect(getByText("Super")).toBeTruthy();
|
||||
expect(queryByText("Win+L")).toBeNull();
|
||||
expect(queryByText("Win")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: { guacd: { status: "disconnected" } } })),
|
||||
post: vi.fn(async () => ({ data: { token: "local-token" } })),
|
||||
}));
|
||||
const remoteApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: { guacd: { status: "connected" } } })),
|
||||
post: vi.fn(async () => ({ data: { token: "remote-token" } })),
|
||||
}));
|
||||
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
getRemoteGuacamoleApi: () => remoteApiMock,
|
||||
isElectron: isElectronMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
vi.mock("@/lib/remote-server-api", () => ({
|
||||
resolveRemoteHostId: resolveRemoteHostIdMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getGuacdStatus,
|
||||
getGuacamoleConnectionId,
|
||||
getGuacamoleTokenFromHost,
|
||||
} from "../../src/frontend/guacamole-api";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
remoteApiMock.get.mockClear();
|
||||
remoteApiMock.post.mockClear();
|
||||
resolveRemoteHostIdMock.mockReset();
|
||||
});
|
||||
|
||||
describe("guacamole API origin", () => {
|
||||
it("uses the shared instance in the browser", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await getGuacdStatus("remote");
|
||||
await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(authApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the connected remote server in the desktop app", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
// The embedded backend has no guacd, so asking it reports "disconnected"
|
||||
// even when the connected server can serve the session.
|
||||
const status = await getGuacdStatus("remote");
|
||||
const token = await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(status.guacd.status).toBe("connected");
|
||||
expect(token.token).toBe("remote-token");
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the connect-host payload unchanged to the remote server", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
resolveRemoteHostIdMock.mockResolvedValue(41);
|
||||
|
||||
await getGuacamoleTokenFromHost(
|
||||
9,
|
||||
"remote",
|
||||
"rdp",
|
||||
{
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "EXAMPLE",
|
||||
},
|
||||
"host-sync-id",
|
||||
);
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
||||
"/guacamole/connect-host/41",
|
||||
{
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
},
|
||||
);
|
||||
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
it("sends an empty prompted domain for local RDP accounts", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await getGuacamoleTokenFromHost(9, "local", "rdp", {
|
||||
username: "local-admin",
|
||||
password: "secret",
|
||||
domain: "",
|
||||
});
|
||||
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
});
|
||||
});
|
||||
|
||||
// Support#1240: a host set to "This Device" is served by the embedded
|
||||
// backend, so its token has to come from there too -- and its id must not
|
||||
// be remapped onto a remote server that may not even be configured.
|
||||
it("asks the embedded backend when the host originates locally", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
|
||||
|
||||
expect(token.token).toBe("local-token");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the embedded backend's own guacd for a local origin", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
const status = await getGuacdStatus("local");
|
||||
|
||||
expect(status.guacd.status).toBe("disconnected");
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the remote server for an explicitly remote origin", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
await getGuacamoleTokenFromHost(9, "remote", "rdp");
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to a colliding local id when sync resolution fails", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
resolveRemoteHostIdMock.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
getGuacamoleTokenFromHost(
|
||||
9,
|
||||
"remote",
|
||||
"rdp",
|
||||
undefined,
|
||||
"missing-sync-id",
|
||||
),
|
||||
).rejects.toThrow("The synced host does not exist on the remote server");
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("looks up the session on the backend that issued its token", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
const signal = new AbortController().signal;
|
||||
await getGuacamoleConnectionId("connect/id", "remote", signal);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||
"/guacamole/connection/connect%2Fid",
|
||||
{ signal },
|
||||
);
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "../../src/frontend/guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole clipboard paste", () => {
|
||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "V",
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the remote clipboard before sending Ctrl+V", () => {
|
||||
const events: string[] = [];
|
||||
const client: GuacamoleClipboardClient = {
|
||||
createClipboardStream: vi.fn((mimetype: string) => {
|
||||
events.push(`stream:${mimetype}`);
|
||||
return {
|
||||
sendBlob: () => events.push("blob"),
|
||||
sendEnd: () => events.push("end"),
|
||||
};
|
||||
}),
|
||||
sendKeyEvent: vi.fn((pressed: number, keysym: number) => {
|
||||
events.push(`key:${pressed}:${keysym}`);
|
||||
}),
|
||||
};
|
||||
|
||||
pasteTextToRemote(client, "Firefox clipboard");
|
||||
|
||||
expect(events).toEqual([
|
||||
"stream:text/plain",
|
||||
"blob",
|
||||
"end",
|
||||
"key:1:65507",
|
||||
"key:1:118",
|
||||
"key:0:118",
|
||||
"key:0:65507",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGuacamoleDisplaySize,
|
||||
readConfiguredDimension,
|
||||
} from "../../src/frontend/guacamole-display-size";
|
||||
|
||||
describe("getGuacamoleDisplaySize", () => {
|
||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "rdp", 2)).toEqual({
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
dpi: 192,
|
||||
pixelRatio: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("scales a configured RDP DPI with the device pixel ratio", () => {
|
||||
expect(getGuacamoleDisplaySize(1000, 600, "rdp", 1.5, 120)).toEqual({
|
||||
width: 1500,
|
||||
height: 900,
|
||||
dpi: 180,
|
||||
pixelRatio: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-RDP protocols at CSS-pixel dimensions", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "vnc", 2)).toEqual({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
pixelRatio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("caps pathological pixel ratios to protect remote session size", () => {
|
||||
expect(getGuacamoleDisplaySize(400, 800, "rdp", 4)).toEqual({
|
||||
width: 1200,
|
||||
height: 2400,
|
||||
dpi: 288,
|
||||
pixelRatio: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a configured resolution in place of the container size", () => {
|
||||
expect(
|
||||
getGuacamoleDisplaySize(800, 600, "rdp", 1, undefined),
|
||||
).toMatchObject({ width: 800, height: 600 });
|
||||
expect(
|
||||
getGuacamoleDisplaySize(1920, 1080, "rdp", 1, undefined),
|
||||
).toMatchObject({ width: 1920, height: 1080 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("readConfiguredDimension", () => {
|
||||
it("accepts the strings the host editor stores", () => {
|
||||
expect(readConfiguredDimension("1920")).toBe(1920);
|
||||
expect(readConfiguredDimension(1080)).toBe(1080);
|
||||
});
|
||||
|
||||
it("treats an unset or unusable value as 'follow the container'", () => {
|
||||
expect(readConfiguredDimension("")).toBeUndefined();
|
||||
expect(readConfiguredDimension(undefined)).toBeUndefined();
|
||||
expect(readConfiguredDimension(null)).toBeUndefined();
|
||||
expect(readConfiguredDimension("auto")).toBeUndefined();
|
||||
expect(readConfiguredDimension("0")).toBeUndefined();
|
||||
expect(readConfiguredDimension("-1080")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canUploadToRdpDrive,
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} from "../../src/frontend/guacamole-file-drop.ts";
|
||||
|
||||
describe("Guacamole file drop", () => {
|
||||
it("recognizes external file drags", () => {
|
||||
expect(hasDraggedFiles(["Files"])).toBe(true);
|
||||
expect(hasDraggedFiles(["text/plain"])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects files when upload is unavailable instead of ignoring them", () => {
|
||||
expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject");
|
||||
expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload");
|
||||
});
|
||||
|
||||
it("accepts an enabled RDP drive before a filesystem object is advertised", () => {
|
||||
expect(canUploadToRdpDrive(true, true, false)).toBe(true);
|
||||
expect(canUploadToRdpDrive(true, false, true)).toBe(true);
|
||||
expect(canUploadToRdpDrive(true, false, false)).toBe(false);
|
||||
expect(canUploadToRdpDrive(false, true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeUploadError } from "../../src/frontend/guacamole-filesystem";
|
||||
|
||||
const t = (key: string) => `#${key}`;
|
||||
|
||||
describe("describeUploadError", () => {
|
||||
it("explains guacd's refusal to open the target file as a drive-folder problem", () => {
|
||||
expect(describeUploadError(new Error("FAIL (CANNOT OPEN)"), t)).toBe(
|
||||
"#driveNotWritable",
|
||||
);
|
||||
expect(describeUploadError(new Error("FAIL (NO FS)"), t)).toBe(
|
||||
"#driveUnavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes other messages through and falls back for unknown errors", () => {
|
||||
expect(describeUploadError(new Error("disk full"), t)).toBe("disk full");
|
||||
expect(describeUploadError(undefined, t)).toBe("#uploadFailed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
} from "../../src/frontend/guacamole-meta-key.js";
|
||||
|
||||
describe("guacamole toolbar meta key labels", () => {
|
||||
it("keeps Windows labels for RDP regardless of the local device", () => {
|
||||
expect(
|
||||
detectMetaKeyFamily("rdp", {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Macintosh; Intel Mac OS X",
|
||||
userAgentDataPlatform: "macOS",
|
||||
}),
|
||||
).toBe("win");
|
||||
});
|
||||
|
||||
it("labels VNC with the local device's modifier instead of Windows", () => {
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Macintosh; Intel Mac OS X",
|
||||
userAgentDataPlatform: "macOS",
|
||||
}),
|
||||
).toBe("cmd");
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "Linux x86_64",
|
||||
userAgent: "X11; Linux x86_64",
|
||||
userAgentDataPlatform: "Linux",
|
||||
}),
|
||||
).toBe("super");
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "Win32",
|
||||
userAgent: "Windows NT 10.0",
|
||||
userAgentDataPlatform: "Windows",
|
||||
}),
|
||||
).toBe("win");
|
||||
});
|
||||
|
||||
it("exposes Super/Cmd button copy for non-Windows VNC sessions", () => {
|
||||
expect(metaKeyLabels("super").short).toBe("Super");
|
||||
expect(metaKeyLabels("super").lock).toBe("Super+L");
|
||||
expect(metaKeyLabels("cmd").short).toBe("Cmd");
|
||||
expect(metaKeyLabels("cmd").lock).toBe("Cmd+L");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindPointerInput } from "../../src/frontend/guacamole-pointer.js";
|
||||
|
||||
function listenedEvents(): {
|
||||
element: HTMLElement;
|
||||
events: () => string[];
|
||||
} {
|
||||
const element = document.createElement("div");
|
||||
const seen: string[] = [];
|
||||
const original = element.addEventListener.bind(element);
|
||||
element.addEventListener = ((type: string, ...rest: unknown[]) => {
|
||||
seen.push(type);
|
||||
return (original as (t: string, ...r: unknown[]) => void)(type, ...rest);
|
||||
}) as typeof element.addEventListener;
|
||||
return { element, events: () => seen };
|
||||
}
|
||||
|
||||
describe("bindPointerInput", () => {
|
||||
// A touchscreen laptop reports maxTouchPoints > 0 and still has a mouse.
|
||||
// Binding only the touch emulator left the pointer dead on those machines.
|
||||
it.each([
|
||||
["no touch mode", null],
|
||||
["touchscreen", "touchscreen" as const],
|
||||
["touchpad", "touchpad" as const],
|
||||
])("keeps the physical pointer bound with %s", (_label, touchMode) => {
|
||||
const { element, events } = listenedEvents();
|
||||
|
||||
bindPointerInput(element, touchMode, vi.fn());
|
||||
|
||||
expect(events()).toContain("mousedown");
|
||||
expect(events()).toContain("mousemove");
|
||||
expect(events()).toContain("mouseup");
|
||||
});
|
||||
|
||||
it("adds touch listeners only when a touch mode is selected", () => {
|
||||
const withoutTouch = listenedEvents();
|
||||
bindPointerInput(withoutTouch.element, null, vi.fn());
|
||||
const plainTouchCount = withoutTouch
|
||||
.events()
|
||||
.filter((e) => e.startsWith("touch")).length;
|
||||
|
||||
const withTouch = listenedEvents();
|
||||
bindPointerInput(withTouch.element, "touchscreen", vi.fn());
|
||||
const touchModeCount = withTouch
|
||||
.events()
|
||||
.filter((e) => e.startsWith("touch")).length;
|
||||
|
||||
expect(touchModeCount).toBeGreaterThan(plainTouchCount);
|
||||
});
|
||||
|
||||
it("does not forward a wheel flood as individual remote scroll clicks", () => {
|
||||
vi.useFakeTimers();
|
||||
const element = document.createElement("div");
|
||||
const onState = vi.fn();
|
||||
const dispose = bindPointerInput(element, null, onState);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: 120,
|
||||
deltaMode: 0,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const presses = onState.mock.calls.filter(
|
||||
([state]) => state.up === true || state.down === true,
|
||||
);
|
||||
expect(presses.length).toBeLessThanOrEqual(2);
|
||||
|
||||
vi.advanceTimersByTime(40);
|
||||
const laterPresses = onState.mock.calls.filter(
|
||||
([state]) => state.up === true || state.down === true,
|
||||
);
|
||||
expect(laterPresses.length).toBeLessThanOrEqual(4);
|
||||
|
||||
dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clampPendingScrollClicks,
|
||||
createScrollCoalescer,
|
||||
isScrollButtonState,
|
||||
MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
SCROLL_FLUSH_INTERVAL_MS,
|
||||
takeScrollClicks,
|
||||
} from "../../src/frontend/guacamole-scroll.js";
|
||||
|
||||
describe("guacamole scroll coalescing", () => {
|
||||
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
|
||||
expect(clampPendingScrollClicks(0, 40)).toBe(MAX_SCROLL_CLICKS_PER_FLUSH);
|
||||
expect(clampPendingScrollClicks(0, -40)).toBe(-MAX_SCROLL_CLICKS_PER_FLUSH);
|
||||
expect(takeScrollClicks(20)).toEqual({
|
||||
clicks: MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
direction: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the first notch immediately and drops later clicks in the same burst", () => {
|
||||
const send = vi.fn();
|
||||
let now = 1_000;
|
||||
const scheduled: Array<{ fn: () => void; ms: number }> = [];
|
||||
const coalescer = createScrollCoalescer(
|
||||
send,
|
||||
() => now,
|
||||
(fn, ms) => {
|
||||
scheduled.push({ fn, ms });
|
||||
return scheduled.length;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
coalescer.notePointer({
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: false,
|
||||
});
|
||||
for (let i = 0; i < 20; i++) coalescer.ingestClick(1);
|
||||
|
||||
const firstBurst = send.mock.calls.map(([state]) => state);
|
||||
expect(firstBurst).toEqual([
|
||||
{
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: true,
|
||||
},
|
||||
{
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: false,
|
||||
},
|
||||
]);
|
||||
|
||||
send.mockClear();
|
||||
now += SCROLL_FLUSH_INTERVAL_MS;
|
||||
expect(scheduled).toHaveLength(1);
|
||||
scheduled[0].fn();
|
||||
expect(send.mock.calls).toHaveLength(MAX_SCROLL_CLICKS_PER_FLUSH * 2);
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
it("recognizes only the scroll buttons as coalesced input", () => {
|
||||
expect(isScrollButtonState({ up: true, down: false })).toBe(true);
|
||||
expect(isScrollButtonState({ up: false, down: true })).toBe(true);
|
||||
expect(isScrollButtonState({ up: false, down: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
const fetchId = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../src/frontend/guacamole-api", () => ({
|
||||
getGuacamoleConnectionId: fetchId,
|
||||
}));
|
||||
import { watchGuacamoleConnectionId } from "../../src/frontend/guacamole-session-id";
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchId.mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it("discovers the sharing ID after handshake without changing the connection origin", async () => {
|
||||
fetchId.mockResolvedValueOnce(null).mockResolvedValueOnce("guacd-id");
|
||||
const ready = vi.fn();
|
||||
const stop = watchGuacamoleConnectionId("connect-id", "remote", ready);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchId).toHaveBeenLastCalledWith(
|
||||
"connect-id",
|
||||
"remote",
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(ready).toHaveBeenCalledExactlyOnceWith("guacd-id");
|
||||
stop();
|
||||
});
|
||||
it("ignores a late response after disconnect or replacement", async () => {
|
||||
let resolve!: (id: string) => void;
|
||||
fetchId.mockReturnValue(
|
||||
new Promise<string>((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
const ready = vi.fn();
|
||||
const stop = watchGuacamoleConnectionId("old", "local", ready);
|
||||
stop();
|
||||
resolve("old-guacd-id");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
expect(fetchId.mock.calls[0][2].aborted).toBe(true);
|
||||
});
|
||||
it("bounds discovery when the session never opens", async () => {
|
||||
fetchId.mockResolvedValue(null);
|
||||
const stop = watchGuacamoleConnectionId("missing", "local", vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(fetchId).toHaveBeenCalledTimes(20);
|
||||
stop();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "../../src/frontend/guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
host: "termix.example.com",
|
||||
} as Location;
|
||||
|
||||
describe("buildGuacamoleWebSocketBaseUrl", () => {
|
||||
it("uses the same origin guacamole websocket path in production web builds", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("preserves the runtime base path for proxied web deployments", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("keeps direct local websocket access for development and embedded electron", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: true,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://localhost:30008");
|
||||
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: true,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:30008");
|
||||
});
|
||||
|
||||
it("uses the configured remote server URL for electron remote mode", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: false,
|
||||
configuredServerUrl: "https://termix.example.com/termix/",
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampGuacamoleZoom,
|
||||
stepGuacamoleZoom,
|
||||
} from "../../src/frontend/guacamole-zoom.js";
|
||||
|
||||
describe("guacamole zoom", () => {
|
||||
it("steps in predictable quarter increments", () => {
|
||||
expect(stepGuacamoleZoom(1, 1)).toBe(1.25);
|
||||
expect(stepGuacamoleZoom(1, -1)).toBe(0.75);
|
||||
});
|
||||
|
||||
it("keeps toolbar and pinch zoom within usable bounds", () => {
|
||||
expect(clampGuacamoleZoom(0.1)).toBe(0.5);
|
||||
expect(clampGuacamoleZoom(8)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { needsRdpCredentialPrompt } from "../../src/frontend/rdp-credential-prompt";
|
||||
|
||||
describe("needsRdpCredentialPrompt", () => {
|
||||
it("prompts recipients when RDP has no saved authentication", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({ protocol: "rdp", rdpAuthType: "none" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prompt when the recipient has a personal override", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({
|
||||
protocol: "rdp",
|
||||
rdpAuthType: "none",
|
||||
authOverrides: {
|
||||
rdp: { credentialId: 7, required: false, ownerAuthShared: true },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not prompt for other protocols", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({ protocol: "vnc", rdpAuthType: "none" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user