fix(web): support SSH host key prompts

This commit is contained in:
t8y2 committed 2026-07-28 12:19:48 +08:00
1 parent cb9d0f7cbf
commit 8ac282cc37
14 files changed
+509 -32

No files matched your search

@@ -0,0 +1,163 @@
// @vitest-environment happy-dom
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 } = 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);
},
});
return {
Dialog: dialog,
DialogContent: passthrough,
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";
class MockEventSource {
static instances: MockEventSource[] = [];
readonly url: string;
onmessage: ((event: MessageEvent<string>) => 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) }));
}
close() {
this.closed = true;
}
}
const mountedApps: App[] = [];
beforeEach(() => {
resolveSshPromptMock.mockReset().mockResolvedValue(undefined);
MockEventSource.instances = [];
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
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("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("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");
});
});