import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import type { WebEndpoint } from "@/types/index"; import type { Host } from "@/types/ui-types"; const openWebEndpointTunnel = vi.hoisted(() => vi.fn()); const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn()); vi.mock("./web-endpoint-api", async (importOriginal) => { // requireNumericHostId is left as the real implementation -- the "no saved // host" case exercises the actual validation, not a re-implementation of it // that could drift. const actual = await importOriginal(); return { ...actual, openWebEndpointTunnel: (...args: unknown[]) => openWebEndpointTunnel(...args), allowInvalidCertificateForOrigin: (...args: unknown[]) => allowInvalidCertificateForOrigin(...args), }; }); const electron = vi.hoisted(() => ({ isElectron: vi.fn(() => true) })); vi.mock("@/lib/electron", () => electron); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); function endpoint(overrides: Partial = {}): WebEndpoint { return { id: "e1", label: "Proxmox", scheme: "https", port: 8006, path: "/", access: "direct", render: "embedded", ...overrides, }; } function host( overrides: Partial = {}, endpoints: WebEndpoint[] = [], ): Host { return { id: "7", ip: "192.168.1.10", webUiConfig: { endpoints }, ...overrides, } as unknown as Host; } let pageHostname = "localhost"; const realLocation = window.location; beforeEach(() => { Object.defineProperty(HTMLIFrameElement.prototype, "credentialless", { configurable: true, writable: true, value: false, }); pageHostname = "localhost"; electron.isElectron.mockReturnValue(true); Object.defineProperty(window, "location", { configurable: true, get: () => ({ ...realLocation, hostname: pageHostname }), }); }); afterEach(() => { cleanup(); delete (HTMLIFrameElement.prototype as { credentialless?: boolean }) .credentialless; Object.defineProperty(window, "location", { configurable: true, value: realLocation, }); openWebEndpointTunnel.mockReset(); allowInvalidCertificateForOrigin.mockReset(); }); describe("WebEndpointTab", () => { it("refuses unsupported environments before creating a tunnel or iframe", async () => { delete (HTMLIFrameElement.prototype as { credentialless?: boolean }) .credentialless; const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await screen.findByText("webEndpoint.isolationUnavailable"); expect(openWebEndpointTunnel).not.toHaveBeenCalled(); expect(document.querySelector("iframe")).toBeNull(); }); it("isolates cookie state and denies parent access, popups and top navigation", async () => { const { WebEndpointTab } = await import("./WebEndpointTab"); render(); const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement; expect( (frame as HTMLIFrameElement & { credentialless: boolean }).credentialless, ).toBe(true); expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms"); expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer"); expect(screen.queryByText("webEndpoint.openExternally")).toBeNull(); }); it("renders an iframe at the direct URL", async () => { const { WebEndpointTab } = await import("./WebEndpointTab"); render(); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://192.168.1.10:8006/", ), ); }); it("opens the tunnel through the numeric host id and frames the loopback URL", async () => { openWebEndpointTunnel.mockResolvedValue(41234); const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => { expect(openWebEndpointTunnel).toHaveBeenCalledWith(7, "e1"); expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://127.0.0.1:41234/", ); }); }); it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => { const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect(allowInvalidCertificateForOrigin).toHaveBeenCalledWith( "https://192.168.1.10:8006", ), ); }); it("reframes at the new port when a reopened tunnel binds a different one", async () => { openWebEndpointTunnel .mockResolvedValueOnce(41234) .mockResolvedValueOnce(51234); const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://127.0.0.1:41234/", ), ); fireEvent.click(screen.getByTitle("webEndpoint.reload")); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://127.0.0.1:51234/", ), ); expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2); }); /** * The case the port-changed test above CANNOT exercise, and the one that * matters most: a live tunnel returns the SAME port on every open, and a * direct endpoint's URL never changes at all. If reload just did * setUrl(resolved), React would bail out of the same-value setState and the * frame would never remount -- so the button would do nothing in the two * most common cases. Capturing the DOM node before and after is what catches * that: a same-value setState never touches the DOM. */ it("remounts the frame on reload even when the resolved URL is unchanged", async () => { openWebEndpointTunnel.mockResolvedValue(41234); const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); const firstFrame = await screen.findByTitle("Proxmox"); expect(firstFrame).toHaveAttribute("src", "https://127.0.0.1:41234/"); fireEvent.click(screen.getByTitle("webEndpoint.reload")); await waitFor(() => expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2)); // Polled rather than asserted once: against the defective implementation // this never becomes true and the test times out -- a real failure, not a // race. await waitFor(() => { expect(screen.getByTitle("Proxmox")).not.toBe(firstFrame); }); }); it("shows the failure reason when the tunnel cannot open", async () => { openWebEndpointTunnel.mockRejectedValue( new Error("Timed out reaching the endpoint port"), ); const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect( screen.getByText(/Timed out reaching the endpoint port/), ).toBeInTheDocument(), ); }); it("shows a plain message when the endpoint id matches nothing on the host", async () => { const { WebEndpointTab } = await import("./WebEndpointTab"); render(); await waitFor(() => expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(), ); expect(openWebEndpointTunnel).not.toHaveBeenCalled(); expect(document.querySelector("iframe")).toBeNull(); }); it("explains that a tunnel needs a saved host, instead of calling the route", async () => { const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect(screen.getByText(/requires a saved host/)).toBeInTheDocument(), ); expect(openWebEndpointTunnel).not.toHaveBeenCalled(); }); it("resolves a direct endpoint normally on the same quick-connect host", async () => { // The positive case: requireNumericHostId must be consulted only on the // tunnel branch, or direct endpoints would break on unsaved hosts too. const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://192.168.1.10:8006/", ), ); expect(openWebEndpointTunnel).not.toHaveBeenCalled(); }); it("frames a tunnel at the OTHER loopback spelling in a web deployment", async () => { // jsdom serves the page at "localhost". The forward binds where the // backend runs, so the browser must reach it on this same machine -- but // NOT at the same host string, or the tunnelled service receives Termix's // session cookie. electron.isElectron.mockReturnValue(false); openWebEndpointTunnel.mockResolvedValue(41234); const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://127.0.0.1:41234/", ), ); // The assertion carrying the security property: whatever host the frame // lands on, it must not be the one serving Termix. const framed = new URL( screen.getByTitle("Proxmox").getAttribute("src") as string, ).hostname; expect(framed).not.toBe(window.location.hostname); }); it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => { electron.isElectron.mockReturnValue(false); const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect( screen.getByText("webEndpoint.tunnelUnreachableFromBrowser"), ).toBeInTheDocument(), ); expect(openWebEndpointTunnel).not.toHaveBeenCalled(); expect(document.querySelector("iframe")).toBeNull(); }); it("refuses when the page host has no separate loopback spelling", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect( screen.getByText("webEndpoint.tunnelSharesSessionCookie"), ).toBeInTheDocument(), ); // Refusing after opening would still have bound the port and still have // leaked on the first frame load. expect(openWebEndpointTunnel).not.toHaveBeenCalled(); expect(document.querySelector("iframe")).toBeNull(); }); /** * The leak the tunnel guard did not cover: a DIRECT endpoint points at the * host's own address, so when that host is the one serving Termix the frame * is same-site with Termix and the browser attaches the `jwt` cookie. A * different port is not a different cookie key. */ it("refuses a direct endpoint on the same host that serves Termix", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example"; const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect( screen.getByText("webEndpoint.directSharesSessionCookie"), ).toBeInTheDocument(), ); // Refusing after framing would have leaked on the very first request. expect(document.querySelector("iframe")).toBeNull(); }); it("refuses a direct endpoint on a sub domain of the page host", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect( screen.getByText("webEndpoint.directSharesSessionCookie"), ).toBeInTheDocument(), ); expect(document.querySelector("iframe")).toBeNull(); }); it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => { electron.isElectron.mockReturnValue(true); pageHostname = "termix.example"; const { WebEndpointTab } = await import("./WebEndpointTab"); render( , ); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://termix.example:8443/", ), ); }); it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; const { WebEndpointTab } = await import("./WebEndpointTab"); render(); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( "src", "https://192.168.1.10:8006/", ), ); }); });