mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
local: vi.fn(),
|
||||
remote: vi.fn(),
|
||||
stream: vi.fn(),
|
||||
poll: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: {},
|
||||
handleApiError: (error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
tunnelApi: {
|
||||
get: mocks.local,
|
||||
defaults: { baseURL: "http://localhost/ssh" },
|
||||
},
|
||||
getRemoteTunnelApi: () => ({ get: mocks.remote }),
|
||||
isElectron: () => true,
|
||||
}));
|
||||
vi.mock("../../api/sse-stream", () => ({
|
||||
streamServerSentEvents: mocks.stream,
|
||||
}));
|
||||
vi.mock("@/lib/adaptive-polling", () => ({ runAdaptivePolling: mocks.poll }));
|
||||
import {
|
||||
getTunnelStatuses,
|
||||
subscribeTunnelStatuses,
|
||||
} from "../../api/tunnel-api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
invoke: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ serverUrl: "https://remote.example" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local connected state when a remote tunnel with the same name is disconnected", async () => {
|
||||
mocks.local.mockResolvedValue({ data: { shared: { status: "connected" } } });
|
||||
mocks.remote.mockResolvedValue({
|
||||
data: {
|
||||
shared: { status: "disconnected" },
|
||||
remoteOnly: { status: "connected" },
|
||||
},
|
||||
});
|
||||
expect(await getTunnelStatuses()).toEqual({
|
||||
shared: { status: "connected" },
|
||||
remoteOnly: { status: "connected" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local state through remote polling and subsequent local SSE updates", async () => {
|
||||
let deliver: (event: { event: string; data: string }) => void = () => {};
|
||||
mocks.stream.mockImplementation((_url, _options, callback) => {
|
||||
deliver = callback;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
let poll: () => Promise<boolean> = async () => false;
|
||||
mocks.poll.mockImplementation((callback) => {
|
||||
poll = callback;
|
||||
return vi.fn();
|
||||
});
|
||||
mocks.remote.mockResolvedValue({
|
||||
data: { shared: { status: "disconnected" } },
|
||||
});
|
||||
const onStatuses = vi.fn();
|
||||
const stop = subscribeTunnelStatuses(onStatuses);
|
||||
await vi.waitFor(() => expect(mocks.poll).toHaveBeenCalled());
|
||||
deliver({
|
||||
event: "statuses",
|
||||
data: JSON.stringify({ shared: { status: "connected" } }),
|
||||
});
|
||||
await poll();
|
||||
expect(onStatuses).toHaveBeenLastCalledWith({
|
||||
shared: { status: "connected" },
|
||||
});
|
||||
deliver({
|
||||
event: "statuses",
|
||||
data: JSON.stringify({ shared: { status: "disconnected" } }),
|
||||
});
|
||||
expect(onStatuses).toHaveBeenLastCalledWith({
|
||||
shared: { status: "disconnected" },
|
||||
});
|
||||
stop();
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getTunnelTypeForMode,
|
||||
getTunnelPortLabels,
|
||||
getTunnelModeDescription,
|
||||
} from "../../../features/tunnel/tunnel-form-utils.js";
|
||||
|
||||
// A fake translate that echoes the key plus any interpolation args so we can
|
||||
// assert which translation key + payload the helpers chose.
|
||||
const t = (key: string, opts?: Record<string, string | number>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key;
|
||||
|
||||
describe("getTunnelTypeForMode", () => {
|
||||
it("maps remote to remote and everything else to local", () => {
|
||||
expect(getTunnelTypeForMode("remote")).toBe("remote");
|
||||
expect(getTunnelTypeForMode("local")).toBe("local");
|
||||
expect(getTunnelTypeForMode("dynamic")).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelPortLabels", () => {
|
||||
it("labels client local mode source/endpoint ports", () => {
|
||||
const labels = getTunnelPortLabels("client", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.destinationPort");
|
||||
});
|
||||
|
||||
it("swaps labels for client remote mode", () => {
|
||||
const labels = getTunnelPortLabels("client", "remote", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
|
||||
});
|
||||
|
||||
it("uses host/endpoint labels in server scope", () => {
|
||||
const labels = getTunnelPortLabels("server", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelModeDescription", () => {
|
||||
const ports = { sourcePort: 8080, endpointPort: 9090 };
|
||||
|
||||
it("selects the client dynamic description with only the source port", () => {
|
||||
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).not.toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the client local description with both ports", () => {
|
||||
const desc = getTunnelModeDescription("client", "local", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the server remote description", () => {
|
||||
const desc = getTunnelModeDescription("server", "remote", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,6 @@ vi.mock("react-i18next", () => ({
|
||||
vi.mock("@/settings/RemoteSyncPanel.tsx", () => ({
|
||||
RemoteSyncPanel: () => null,
|
||||
}));
|
||||
vi.mock("@/user/C2STunnelPresetManager", () => ({
|
||||
C2STunnelPresetManager: () => null,
|
||||
}));
|
||||
vi.mock("@/i18n/i18n", () => ({
|
||||
changeAppLanguage: vi.fn(),
|
||||
normalizeLanguageCode: (code: string) => code,
|
||||
|
||||
@@ -12,7 +12,7 @@ const ALL_GROUPS = new Set<FieldGroup>([
|
||||
"connection",
|
||||
"notes",
|
||||
"tags",
|
||||
"tunnels",
|
||||
"proxy",
|
||||
"jumpHosts",
|
||||
"quickActions",
|
||||
"featureFlags",
|
||||
|
||||
@@ -48,11 +48,11 @@ describe("RAIL_ITEMS", () => {
|
||||
// Guards against a destination silently disappearing now that the rail,
|
||||
// the settings toggles, the sidebar titles and the mobile bar all read
|
||||
// from this one list.
|
||||
// "sftp" moved to the file-manager plugin's own registerRailItem.
|
||||
// "sftp" moved to the file-manager plugin's own registerRailItem, and
|
||||
// "port-forwarding" to the tunnels plugin's.
|
||||
expect(RAIL_ITEMS.map((item) => item.id)).toEqual([
|
||||
"hosts",
|
||||
"credentials",
|
||||
"port-forwarding",
|
||||
"termix-id",
|
||||
"connections",
|
||||
"collab",
|
||||
|
||||
@@ -60,6 +60,7 @@ const baseHost: Host = {
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableTunnel: true,
|
||||
enableTmuxMonitor: true,
|
||||
enableFileManager: true,
|
||||
enableDocker: true,
|
||||
enableRdp: true,
|
||||
@@ -219,16 +220,16 @@ describe("HostItem density parity", () => {
|
||||
|
||||
it("learns repeated local actions and preloads the preferred host tool", () => {
|
||||
renderHostItem("comfortable");
|
||||
const tunnelButton = screen.getByTitle("Tunnel");
|
||||
fireEvent.click(tunnelButton);
|
||||
fireEvent.click(tunnelButton);
|
||||
fireEvent.click(tunnelButton);
|
||||
const tmuxButton = screen.getByTitle("Tmux Monitor");
|
||||
fireEvent.click(tmuxButton);
|
||||
fireEvent.click(tmuxButton);
|
||||
fireEvent.click(tmuxButton);
|
||||
|
||||
const hostRow = screen.getByText("web-01").closest(".cursor-pointer");
|
||||
expect(hostRow).toBeTruthy();
|
||||
fireEvent.pointerEnter(hostRow!);
|
||||
|
||||
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("terminal");
|
||||
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("tunnel");
|
||||
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("tmux_monitor");
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeClientTunnel,
|
||||
stripClientTunnelDiagnostics,
|
||||
type ClientTunnel,
|
||||
} from "../../user/c2s-tunnel-config-utils.js";
|
||||
|
||||
describe("C2STunnelPresetManager tunnel normalization", () => {
|
||||
it("preserves explicit local and remote address fields in saved config", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: " 127.0.0.2 ",
|
||||
remoteAddress: " 10.10.0.25 ",
|
||||
sourceHostId: 42,
|
||||
sourceHostSyncId: "host-sync-42",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 5432,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
localAddress: "127.0.0.2",
|
||||
remoteAddress: "10.10.0.25",
|
||||
bindHost: "127.0.0.2",
|
||||
targetHost: "10.10.0.25",
|
||||
sourceHostId: 42,
|
||||
sourceHostSyncId: "host-sync-42",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 5432,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves source host sync identity for cross-device C2S presets", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.25",
|
||||
sourceHostId: 5,
|
||||
sourceHostSyncId: "shared-host-sync-id",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
sourceHostId: 5,
|
||||
sourceHostSyncId: "shared-host-sync-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes legacy bindHost and targetHost configs into address fields", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
bindHost: "127.0.0.3",
|
||||
targetHost: "172.16.1.50",
|
||||
sourceHostId: 7,
|
||||
sourcePort: 9000,
|
||||
endpointPort: 9443,
|
||||
maxRetries: 1,
|
||||
retryInterval: 5,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
expect(tunnel).toMatchObject({
|
||||
localAddress: "127.0.0.3",
|
||||
remoteAddress: "172.16.1.50",
|
||||
bindHost: "127.0.0.3",
|
||||
targetHost: "172.16.1.50",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves tunnel name spacing while editing", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
displayName: "Axolot ",
|
||||
});
|
||||
|
||||
expect(tunnel.displayName).toBe("Axolot ");
|
||||
});
|
||||
|
||||
it("trims tunnel names only when saving", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.10",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
displayName: " Axolot MO ",
|
||||
});
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
displayName: "Axolot MO",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes diagnostics without dropping routing fields", () => {
|
||||
const tunnel: ClientTunnel = {
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.10",
|
||||
bindHost: "127.0.0.1",
|
||||
targetHost: "10.0.0.10",
|
||||
sourceHostId: 1,
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
endpointHost: "edge",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
displayName: "web",
|
||||
lastStartedAt: "2026-07-20T00:00:00.000Z",
|
||||
lastTestedAt: "2026-07-20T00:01:00.000Z",
|
||||
lastError: "previous error",
|
||||
};
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).not.toHaveProperty("lastStartedAt");
|
||||
expect(saved).not.toHaveProperty("lastTestedAt");
|
||||
expect(saved).not.toHaveProperty("lastError");
|
||||
expect(saved).toMatchObject({
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.10",
|
||||
sourceHostId: 1,
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user