Files
dbx/apps/desktop/src/components/ssh/__tests__/SshHostKeyPromptDialog.spec.ts
T

545 lines
18 KiB
TypeScript
Vendored

// @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<string>) => 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<HTMLElement>(".dialog-positioner.z-\\[200\\]");
const promptOverlay = document.body.querySelector<HTMLElement>(".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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLInputElement>("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<HTMLButtonElement>("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<HTMLInputElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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,
});
});
});
});