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:
ZacharyZcR
2026-08-28 10:36:39 +08:00
committed by GitHub
parent 703e8cd037
commit c129666d7f
28 changed files with 689 additions and 157 deletions
+51 -2
View File
@@ -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();
});
});
+18 -2
View File
@@ -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);
});
});