mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 02:41:34 +00:00
fix: harden connection, payload, and persisted state handling (#1354)
* fix: clean up Cloudflare tunnel timeouts * fix: couple tunnel socket lifecycle * fix: validate Docker console messages * fix: bound homepage proxy responses * fix: bound reconnect and response failures * fix: harden persisted and socket state * fix: support local connections to shared hosts
This commit is contained in:
@@ -9,6 +9,7 @@ vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi }));
|
||||
|
||||
import {
|
||||
getConnectedRemoteApi,
|
||||
hydrateLocalSharedHostAuth,
|
||||
markRemoteSharedHosts,
|
||||
resolveRemoteHostId,
|
||||
} from "@/lib/remote-server-api";
|
||||
@@ -50,15 +51,63 @@ describe("remote server API", () => {
|
||||
it("keeps only shared remote hosts and gives them collision-free ids", () => {
|
||||
const rows = markRemoteSharedHosts([
|
||||
{ id: 4, isShared: false },
|
||||
{ id: 9, isShared: true, syncId: "shared-host" },
|
||||
{
|
||||
id: 9,
|
||||
isShared: true,
|
||||
syncId: "shared-host",
|
||||
connectionOrigin: "local",
|
||||
},
|
||||
] as SSHHost[]);
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: -9,
|
||||
syncId: "shared-host",
|
||||
connectionOrigin: "remote",
|
||||
connectionOrigin: "local",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates a remote shared host for a local connection without persisting remote ids", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
invoke.mockResolvedValue({ serverUrl: "https://termix.example" });
|
||||
remoteApi.get.mockResolvedValue({
|
||||
data: {
|
||||
username: "recipient",
|
||||
authType: "key",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "passphrase",
|
||||
keyType: "ed25519",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await hydrateLocalSharedHostAuth({
|
||||
id: -9,
|
||||
isShared: true,
|
||||
syncId: "shared-host",
|
||||
credentialId: 77,
|
||||
username: "owner",
|
||||
authType: "key",
|
||||
});
|
||||
|
||||
expect(remoteApi.get).toHaveBeenCalledWith(
|
||||
"/host/db/host/9/local-connection-auth",
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: -9,
|
||||
syncId: null,
|
||||
credentialId: undefined,
|
||||
username: "recipient",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "passphrase",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves local and owned hosts untouched", async () => {
|
||||
const host = { id: 9, isShared: false, username: "root" };
|
||||
await expect(hydrateLocalSharedHostAuth(host)).resolves.toBe(host);
|
||||
expect(remoteApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeReconnectDelay } from "../../lib/useConnectionRetry.ts";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
computeReconnectDelay,
|
||||
useConnectionRetry,
|
||||
} from "../../lib/useConnectionRetry.ts";
|
||||
|
||||
describe("connection retry delay", () => {
|
||||
it("uses exponential backoff with bounded jitter", () => {
|
||||
@@ -12,3 +16,15 @@ describe("connection retry delay", () => {
|
||||
expect(computeReconnectDelay(8, 2000, 8000, () => 1)).toBe(8000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useConnectionRetry", () => {
|
||||
it("turns a rejected async connection into a failed state", async () => {
|
||||
const connect = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
const { result } = renderHook(() =>
|
||||
useConnectionRetry({ connect, enabled: false }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe("error"));
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,14 @@ const toast = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
const remote = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => api);
|
||||
vi.mock("@/lib/remote-server-api", () => ({
|
||||
getConnectedRemoteApi: vi.fn(async () => remote),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -49,6 +56,7 @@ beforeEach(() => {
|
||||
api.setHostAuthOverride.mockReset();
|
||||
toast.success.mockReset();
|
||||
toast.error.mockReset();
|
||||
remote.get.mockReset();
|
||||
api.getCredentials.mockResolvedValue([
|
||||
{
|
||||
id: 7,
|
||||
@@ -68,6 +76,7 @@ beforeEach(() => {
|
||||
success: true,
|
||||
credentialId: 8,
|
||||
});
|
||||
remote.get.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -150,6 +159,37 @@ describe("HostAuthOverrideModal", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads credentials and overrides from the remote server for remote-only shared hosts", async () => {
|
||||
remote.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 19,
|
||||
name: "Remote personal key",
|
||||
username: "alice",
|
||||
authType: "key",
|
||||
},
|
||||
],
|
||||
});
|
||||
api.getHostAuthOverride.mockResolvedValue({ credentialId: 19 });
|
||||
|
||||
render(
|
||||
<HostAuthOverrideModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
host={{ ...host, id: "-42" }}
|
||||
protocol="ssh"
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText(
|
||||
"hosts.sharing.authOverrideCredentialLabel",
|
||||
);
|
||||
expect((select as HTMLSelectElement).value).toBe("19");
|
||||
expect(remote.get).toHaveBeenCalledWith("/credentials");
|
||||
expect(api.getCredentials).not.toHaveBeenCalled();
|
||||
expect(api.getHostAuthOverride).toHaveBeenCalledWith(-42, "ssh", true);
|
||||
});
|
||||
|
||||
it("renders empty and load-error states", async () => {
|
||||
api.getCredentials.mockResolvedValueOnce([]);
|
||||
const { unmount } = render(
|
||||
|
||||
@@ -15,4 +15,18 @@ describe("sshHostToHost", () => {
|
||||
|
||||
expect(host.wolBroadcastAddress).toBe("192.168.0.255");
|
||||
});
|
||||
|
||||
it("preserves remote shared-host identity for local connection auth", () => {
|
||||
const host = sshHostToHost({
|
||||
id: -12,
|
||||
name: "shared",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
isShared: true,
|
||||
} as SSHHostWithStatus);
|
||||
|
||||
expect(host.id).toBe("-12");
|
||||
expect(host.isShared).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { readHiddenRailTabs } from "../../sidebar/hidden-rail-tabs";
|
||||
|
||||
describe("readHiddenRailTabs", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("returns stored tab identifiers", () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["ai", "hosts"]));
|
||||
expect([...readHiddenRailTabs()]).toEqual(["ai", "hosts"]);
|
||||
});
|
||||
|
||||
it("recovers from malformed or non-list storage", () => {
|
||||
localStorage.setItem("hiddenRailTabs", "{broken");
|
||||
expect(readHiddenRailTabs().size).toBe(0);
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify({ ai: true }));
|
||||
expect(readHiddenRailTabs().size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user