// @vitest-environment happy-dom import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createApp, defineComponent, h, nextTick, type App } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; const { resolveSshPromptMock } = vi.hoisted(() => ({ resolveSshPromptMock: vi.fn(), })); vi.mock("@/lib/backend/api", () => ({ resolveSshPrompt: resolveSshPromptMock })); vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); vi.mock("@/components/ui/dialog", async () => { const { defineComponent, h, Teleport } = await import("vue"); const passthrough = defineComponent({ setup(_props, { slots }) { return () => h("div", slots.default?.()); }, }); const dialog = defineComponent({ props: { open: Boolean }, setup(props, { slots }) { return () => (props.open ? h("div", slots.default?.()) : null); }, }); const dialogContent = defineComponent({ props: { class: [String, Array, Object], overlayClass: [String, Array, Object], portalClass: [String, Array, Object], }, inheritAttrs: false, setup(props, { slots }) { return () => h(Teleport, { to: "body" }, [h("div", { class: ["dialog-overlay", "z-50", props.overlayClass] }), h("div", { class: ["dialog-positioner", "z-50", props.portalClass] }, [h("div", { class: ["dialog-content", props.class] }, slots.default?.())])]); }, }); return { Dialog: dialog, DialogContent: dialogContent, DialogDescription: passthrough, DialogFooter: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, }; }); vi.mock("@/components/ui/button", async () => { const { defineComponent, h } = await import("vue"); return { Button: defineComponent({ inheritAttrs: false, setup(_props, { attrs, slots }) { return () => h("button", attrs, slots.default?.()); }, }), }; }); import SshHostKeyPromptDialog from "@/components/ssh/SshHostKeyPromptDialog.vue"; const dialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue"), "utf8"); class MockEventSource { static instances: MockEventSource[] = []; readonly url: string; onmessage: ((event: MessageEvent) => void) | null = null; onopen: (() => void) | null = null; onerror: (() => void) | null = null; closed = false; constructor(url: string | URL) { this.url = String(url); MockEventSource.instances.push(this); } emit(data: unknown) { this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) })); } open() { this.onopen?.(); } error() { this.onerror?.(); } close() { this.closed = true; } } const mountedApps: App[] = []; beforeEach(() => { resolveSshPromptMock.mockReset().mockResolvedValue(undefined); MockEventSource.instances = []; vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource); vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] } as Response)); i18n.global.locale.value = "en"; }); afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; vi.unstubAllGlobals(); }); async function mountDialog() { const container = document.createElement("div"); document.body.append(container); const app = createApp( defineComponent({ setup() { return () => h(SshHostKeyPromptDialog); }, }), ); mountedApps.push(app); app.use(i18n); app.mount(container); await nextTick(); } describe("SshHostKeyPromptDialog web bridge", () => { it("requires an explicit answer for blocking SSH prompts", () => { expect(dialogSource).toContain(':show-close-button="false"'); expect(dialogSource).toContain("@interact-outside.prevent"); expect(dialogSource).toContain("@escape-key-down.prevent"); expect(dialogSource).not.toContain(':dismissible="false"'); }); it("keeps the prompt above a later body-mounted connection dialog and answerable", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "prompt-layered", kind: "HostKeyVerify", host: "layered.example.test", port: 22, key_type: "ssh-ed25519", fingerprint: "SHA256:layered", }, }); await nextTick(); // Simulate ConnectionDialog opening after the global prompt: its portal is // appended later and remains on the shared default z-50 layer. const connectionPositioner = document.createElement("div"); connectionPositioner.className = "dialog-positioner z-50"; connectionPositioner.textContent = "New connection"; document.body.append(connectionPositioner); const promptPositioner = document.body.querySelector(".dialog-positioner.z-\\[200\\]"); const promptOverlay = document.body.querySelector(".dialog-overlay.z-\\[200\\]"); expect(promptPositioner).not.toBeNull(); expect(promptOverlay).not.toBeNull(); expect(connectionPositioner.classList.contains("z-50")).toBe(true); expect(promptPositioner?.classList.contains("z-[200]")).toBe(true); expect(promptOverlay?.classList.contains("z-[200]")).toBe(true); expect(document.body.textContent).toContain("layered.example.test:22"); const buttons = document.body.querySelectorAll("button"); buttons.item(buttons.length - 1).click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "prompt-layered", action: "accept", remember: true, secret: undefined, }); }); }); it("shows an SSE host-key prompt and posts the user's acceptance", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; expect(eventSource?.url).toBe("/api/ssh/prompts"); eventSource?.emit({ type: "prompt", request: { id: "prompt-1", kind: "HostKeyVerify", host: "ssh.example.test", port: 22, key_type: "ssh-ed25519", fingerprint: "SHA256:test-fingerprint", }, }); await nextTick(); expect(document.body.textContent).toContain("ssh.example.test:22"); expect(document.body.textContent).toContain("SHA256:test-fingerprint"); const buttons = document.body.querySelectorAll("button"); buttons.item(buttons.length - 1).click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "prompt-1", action: "accept", remember: true, secret: undefined, }); }); }); it("shows a changed host-key prompt and updates the saved fingerprint", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "changed-1", kind: "HostKeyChanged", host: "192.168.1.111", port: 22, key_type: "ssh-ed25519", fingerprint: "SHA256:new-fingerprint", previous_fingerprint: "SHA256:old-fingerprint", }, }); await nextTick(); expect(document.body.textContent).toContain("Host fingerprint has changed"); expect(document.body.textContent).toContain("new-fingerprint"); expect(document.body.textContent).toContain("old-fingerprint"); expect(document.body.textContent).toContain("Saved fingerprint"); const buttons = [...document.body.querySelectorAll("button")].map((button) => button.textContent?.trim()); expect(buttons).toContain("Close"); expect(buttons).toContain("Continue"); expect(buttons).toContain("Update and Continue"); const update = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Update and Continue"); update?.click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "changed-1", action: "accept", remember: true, secret: undefined, }); }); }); it("continues a changed host key for this session only", async () => { await mountDialog(); MockEventSource.instances[0]?.emit({ type: "prompt", request: { id: "changed-2", kind: "HostKeyChanged", host: "board.example.test", port: 22, key_type: "ssh-ed25519", fingerprint: "SHA256:new", previous_fingerprint: "SHA256:old", }, }); await nextTick(); const cont = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Continue"); cont?.click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "changed-2", action: "accept", remember: false, secret: undefined, }); }); }); it("clears prompts that are no longer pending after an SSE reconnect snapshot", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "prompt-2", kind: "HostKeyVerify", host: "stale.example.test", port: 22, key_type: "ssh-ed25519", fingerprint: "SHA256:stale", }, }); await nextTick(); expect(document.body.textContent).toContain("stale.example.test:22"); eventSource?.emit({ type: "sync", pendingIds: [] }); await nextTick(); expect(document.body.textContent).not.toContain("stale.example.test:22"); }); it("recovers a pending host-key prompt via the polling fallback when the SSE event is missed", async () => { // Simulate a prompt that fired before the EventSource was open: the backend // has it pending, but the SSE `Prompt` event never reached the dialog. The // polling fallback must surface it so the user can still confirm. const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ ok: true, json: async () => [ { id: "prompt-3", kind: "HostKeyVerify", host: "first.example.test", port: 22, key_type: "ssh-ed25519", fingerprint: "SHA256:first", }, ], } as Response); await mountDialog(); await nextTick(); await vi.waitFor(() => { expect(document.body.textContent).toContain("first.example.test:22"); }); expect(fetchMock).toHaveBeenCalledWith("/api/ssh/prompts/pending", { credentials: "include" }); }); it("stops polling once the SSE stream opens and resumes it after an error", async () => { const setSpy = vi.spyOn(globalThis, "setInterval"); const clearSpy = vi.spyOn(globalThis, "clearInterval"); await mountDialog(); const eventSource = MockEventSource.instances[0]; // Polling is armed on mount (first-connect fallback). expect(setSpy).toHaveBeenCalled(); // Opening the stream stops the poller (SSE replay now covers lost prompts). eventSource?.open(); expect(clearSpy).toHaveBeenCalled(); // A disconnect re-arms polling so prompts are not lost during the outage. eventSource?.error(); expect(setSpy).toHaveBeenCalledTimes(2); }); it("shows worker-upload consent buttons and wraps the digest and path", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "worker-1", kind: "WorkerUploadConsent", host: "203.0.113.10", port: 22, fingerprint: "08ca4746e8fbf97038a93105d3ef023112e3f66bc097869401628c00acea7709", prompt: "/home/testuser/.cache/dbx/sqlite-worker/session-ebd207de-8f26cd6e-08ca4746e8fbf97038a93105d3ef023112e3f66bc097869401628c00acea7709", }, }); await nextTick(); expect(document.body.textContent).toContain("203.0.113.10:22"); expect(document.body.textContent).toContain("08ca4746e8fbf97038a93105d3ef023112e3f66bc097869401628c00acea7709"); expect(document.body.textContent).toContain("/home/testuser/.cache/dbx/sqlite-worker/"); expect(dialogSource).toContain("break-all"); expect(dialogSource).toContain("shrink-0"); const buttons = [...document.body.querySelectorAll("button")].map((button) => button.textContent?.trim()); expect(buttons).toContain("Cancel"); expect(buttons).toContain("Upload"); }); it("submits a keyboard-interactive TOTP challenge as a secret response", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "totp-1", kind: "SecretInput", host: "jump.example.test", port: 2222, prompt: "JumpServer\n\nOTP Code:", echo: false, }, }); await nextTick(); expect(document.body.textContent).toContain("SSH Verification Required"); expect(document.body.textContent).toContain("OTP Code:"); const input = document.body.querySelector("input"); expect(input?.type).toBe("password"); if (!input) throw new Error("TOTP input was not rendered"); input.value = "123456"; input.dispatchEvent(new Event("input", { bubbles: true })); await nextTick(); const buttons = document.body.querySelectorAll("button"); buttons.item(buttons.length - 1).click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "totp-1", action: "secret", remember: undefined, secret: "123456", }); }); }); it("relays a plugin question and submits the typed answer", async () => { // Host API `host/requestUserInput`: a plugin backend (e.g. the SSH plugin // answering a bastion's keyboard-interactive MFA) asks through the same // dialog, and the host only carries the question and the typed value. await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "plugin-input-1", kind: "UserInput", host: "", port: 0, prompt: "Verification code (6 digits)", title: "JumpServer login", source: "SSH Terminal", echo: false, default_value: "12", }, }); await nextTick(); expect(document.body.textContent).toContain("JumpServer login"); expect(document.body.textContent).toContain("SSH Terminal needs input before it can continue."); expect(document.body.textContent).toContain("Verification code (6 digits)"); const input = document.body.querySelector("input"); expect(input?.type).toBe("password"); // A caller-provided default is offered but still has to be submitted. expect(input?.value).toBe("12"); if (!input) throw new Error("plugin prompt input was not rendered"); input.value = "654321"; input.dispatchEvent(new Event("input", { bubbles: true })); await nextTick(); const buttons = [...document.body.querySelectorAll("button")]; expect(buttons.map((button) => button.textContent?.trim())).toEqual(["Cancel", "Submit"]); buttons[buttons.length - 1].click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "plugin-input-1", action: "secret", remember: undefined, secret: "654321", }); }); }); it("answers a fixed-choice plugin question by picking an option", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "plugin-choice-1", kind: "UserInput", host: "", port: 0, title: "Select account", prompt: "Which bastion account should be used?", source: "LDAP Directory", options: [ { value: "jinpy", label: "jinpy (admin)" }, { value: "deploy", label: "deploy (read-only)" }, ], }, }); await nextTick(); expect(document.body.textContent).toContain("Which bastion account should be used?"); const buttons = [...document.body.querySelectorAll("button")]; // Options replace the free-form input, so only the choices plus Cancel show. expect(buttons.map((button) => button.textContent?.trim())).toEqual(["jinpy (admin)", "deploy (read-only)", "Cancel"]); expect(document.body.querySelector("input")).toBeNull(); buttons.find((button) => button.textContent?.trim() === "deploy (read-only)")?.click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "plugin-choice-1", action: "secret", remember: undefined, secret: "deploy", }); }); }); it("cancels a plugin question without answering it", async () => { await mountDialog(); const eventSource = MockEventSource.instances[0]; eventSource?.emit({ type: "prompt", request: { id: "plugin-input-cancel", kind: "UserInput", host: "", port: 0, prompt: "Verification code", source: "SSH Terminal", }, }); await nextTick(); const cancel = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Cancel"); cancel?.click(); await vi.waitFor(() => { expect(resolveSshPromptMock).toHaveBeenCalledWith({ id: "plugin-input-cancel", action: "reject", remember: undefined, secret: undefined, }); }); }); });