import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // vi.mock is hoisted per file; the factories resolve to the shared handles // exported from ./helpers/ws-mocks (see that module's doc comment). vi.mock("@tauri-apps/api/core", async () => ({ invoke: (await import("./helpers/ws-mocks")).mockInvoke, })); vi.mock("@tauri-apps/api/event", async () => ({ listen: (await import("./helpers/ws-mocks")).mockListen, })); import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks"; import { createWsClient } from "../../src/lib/ws"; describe("cert mismatch blocking", () => { let client: ReturnType; beforeEach(() => { vi.useFakeTimers(); mockInvoke.mockReset(); mockInvoke.mockResolvedValue(undefined); mockListen.mockClear(); eventHandlers.clear(); client = createWsClient(); }); afterEach(() => { client.disconnect(); vi.useRealTimers(); }); it("should block reconnect when cert mismatch detected", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", seq: 1, payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Cert mismatch event fires emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "mismatch", message: "Stored: sha256:OLD", }); expect(client.getState()).toBe("disconnected"); // Connection closes after mismatch emitTauriEvent("ws-state", "closed"); // Wait well beyond normal backoff — should NOT reconnect mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(60_000); const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls).toHaveLength(0); }); it("blocks reconnect when the profile host carries an explicit :443 that the Rust proxy normalizes away", async () => { // Regression for v052: config.host is stored verbatim (e.g. a profile // saved as "example.com:443"), but the Rust proxies emit the event host // through tofu::cert_store_key, which strips a trailing ":443". An // un-normalized comparison would miss this match and the reconnect loop // would keep re-handshaking the untrusted host every backoff interval. client.connect({ host: "example.com:443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", seq: 1, payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Rust-normalized event host — no ":443" suffix. emitTauriEvent("cert-tofu", { host: "example.com", fingerprint: "sha256:NEW", status: "mismatch", message: "Stored: sha256:OLD", }); expect(client.getState()).toBe("disconnected"); emitTauriEvent("ws-state", "closed"); mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(60_000); const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls).toHaveLength(0); }); it("blocks reconnect when the profile host's case differs from the Rust proxy's lowercased event host", async () => { // Regression: config.host is stored as the user typed it (e.g. // "Example.COM:8443" from a profile), but tofu::cert_store_key // case-folds to lowercase before the proxy emits the cert-tofu event. // An un-normalized (case-sensitive) comparison here would miss the // match and never latch this connection's reconnect loop. client.connect({ host: "Example.COM:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", seq: 1, payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Rust-normalized event host — lowercase. emitTauriEvent("cert-tofu", { host: "example.com:8443", fingerprint: "sha256:NEW", status: "mismatch", message: "Stored: sha256:OLD", }); expect(client.getState()).toBe("disconnected"); emitTauriEvent("ws-state", "closed"); mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(60_000); const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls).toHaveLength(0); }); it("blocks reconnect when a bracketed IPv6 profile host meets the Rust proxy's unbracketed event host", async () => { // Regression for OC-0163's TS half: a profile may be saved bracketed // ("[2001:db8::1]", a form hostValidation.ts's isValidHost accepts), and // ws_connect embeds it verbatim in the wss:// authority — so the Rust // side derives the pin key via tofu::cert_store_key, which unwraps a // PORTLESS bracketed IPv6 literal and emits the bare host. Without the // matching unwrap in normalizeHostForCertCompare, this guard never // matches for an IPv6 server and the reconnect loop keeps re-handshaking // the host whose certificate just changed. client.connect({ host: "[2001:db8::1]", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", seq: 1, payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Rust-normalized event host — brackets stripped by cert_store_key. emitTauriEvent("cert-tofu", { host: "2001:db8::1", fingerprint: "sha256:NEW", status: "mismatch", message: "Stored: sha256:OLD", }); expect(client.getState()).toBe("disconnected"); emitTauriEvent("ws-state", "closed"); mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(60_000); const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls).toHaveLength(0); }); it("should unblock after acceptCertFingerprint", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", seq: 1, payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "mismatch", message: "Stored: sha256:OLD", }); expect(client.getState()).toBe("disconnected"); // Accept the new fingerprint await client.acceptCertFingerprint("localhost:8443", "sha256:NEW"); // Now a manual reconnect should work mockInvoke.mockClear(); client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); }); it("routes first_use cert events to onCertFirstUse, not onCertMismatch (F4/F8)", async () => { const firstUse: unknown[] = []; const mismatch: unknown[] = []; client.onCertFirstUse((e) => firstUse.push(e)); client.onCertMismatch((e) => mismatch.push(e)); client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "first_use", }); expect(firstUse).toHaveLength(1); expect(mismatch).toHaveLength(0); }); it("startCertListener catches cert events before any WS connect (connect-page path)", async () => { const firstUse: unknown[] = []; client.onCertFirstUse((e) => firstUse.push(e)); // No connect() — main.ts registers the listener at bootstrap so first-use // fires during the connect page's health check, before login. await client.startCertListener(); emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "first_use", }); expect(firstUse).toHaveLength(1); }); it("should not schedule reconnect when certMismatchBlock is true", async () => { const mismatchEvents: unknown[] = []; client.onCertMismatch((evt) => mismatchEvents.push(evt)); client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", seq: 1, payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Trigger mismatch emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:CHANGED", status: "mismatch", message: "Stored: sha256:ORIGINAL", }); expect(mismatchEvents).toHaveLength(1); // Connection drops emitTauriEvent("ws-state", "closed"); // State should remain disconnected, not reconnecting expect(client.getState()).toBe("disconnected"); mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(60_000); const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnects).toHaveLength(0); }); it("does not latch or drop state on a mismatch for a different (unrelated) host", async () => { const mismatchEvents: unknown[] = []; client.onCertMismatch((evt) => mismatchEvents.push(evt)); client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); expect(client.getState()).toBe("connected"); // A rotated cert on a DIFFERENT saved profile (e.g. the connect page's // 15s health-check loop probing another server) must not touch this // connection at all. emitTauriEvent("cert-tofu", { host: "other.example:8443", fingerprint: "sha256:NEW", status: "mismatch", message: "Stored: sha256:OLD", }); // Still notified — so a connect-page prompt for that OTHER host works... expect(mismatchEvents).toHaveLength(1); // ...but this unrelated connection must not be latched or disconnected. expect(client.getState()).toBe("connected"); // And it must keep reconnecting normally after a later drop. emitTauriEvent("ws-state", "closed"); mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(2000); const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls.length).toBeGreaterThan(0); }); it("connect() resets certMismatchBlock even when not preceded by disconnect()", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "mismatch", }); expect(client.getState()).toBe("disconnected"); // A fresh connect() call — not preceded by disconnect() or // acceptCertFingerprint() (e.g. the suppressed-modal path where a second // host's mismatch latched the flag while a first-use modal was open, and // the user logs in anyway) — must clear the stale latch itself. client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent("ws-state", "closed"); // drop again, still unauthenticated mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(2000); const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls.length).toBeGreaterThan(0); }); it("a mismatch latched while a reconnect is already pending cannot be bypassed by that timer", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Socket drops first: a reconnect timer is now armed and counting down. emitTauriEvent("ws-state", "closed"); expect(client.getState()).toBe("reconnecting"); // The mismatch arrives DURING that backoff (e.g. the connect page's // 15s health check re-probing this same host). Latching the flag is not // enough on its own — the already-armed timer still fires connect(), // which clears the latch, so the reconnect loop resumes against a host // whose certificate just changed. The latch must cancel it. emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "mismatch", }); expect(client.getState()).toBe("disconnected"); mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(10_000); const bypassed = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(bypassed).toHaveLength(0); }); }); describe("disconnect() resets reconnectAttempt", () => { let client: ReturnType; beforeEach(() => { vi.useFakeTimers(); mockInvoke.mockReset(); mockInvoke.mockResolvedValue(undefined); mockListen.mockClear(); eventHandlers.clear(); client = createWsClient(); }); afterEach(() => { client.disconnect(); vi.useRealTimers(); }); it("resets the backoff exponent so a later session's first retry uses the short delay", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); // One drop before auth_ok, letting the scheduled retry fire — grows // reconnectAttempt to 1 (the next backoff would double to 2s). emitTauriEvent("ws-state", "closed"); await vi.advanceTimersByTimeAsync(1000); emitTauriEvent("ws-state", "open"); // Abandon this session mid-backoff (reconnectAttempt is now 1). client.disconnect(); // A brand new session — its first handshake also drops before auth_ok. mockInvoke.mockClear(); client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent("ws-state", "closed"); // If reconnectAttempt carried over (still 1) the next backoff would be // 2000ms; reset to 0 it is the base 1000ms — so a reconnect must have // fired by exactly 1000ms. mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(1000); const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls.length).toBeGreaterThan(0); }); }); describe("parseStoredFingerprint", () => { // Import the pure function directly let parseStoredFingerprint: typeof import("../../src/lib/ws").parseStoredFingerprint; beforeEach(async () => { const mod = await import("../../src/lib/ws"); parseStoredFingerprint = mod.parseStoredFingerprint; }); it("returns undefined for undefined input", () => { expect(parseStoredFingerprint(undefined)).toBeUndefined(); }); it("returns undefined for empty string", () => { expect(parseStoredFingerprint("")).toBeUndefined(); }); it("returns undefined when no Stored: prefix found", () => { expect(parseStoredFingerprint("no match here")).toBeUndefined(); }); it("extracts fingerprint after Stored: prefix", () => { expect(parseStoredFingerprint("Stored: sha256:ABCDEF")).toBe("sha256:ABCDEF"); }); it("extracts first non-whitespace token after Stored:", () => { expect(parseStoredFingerprint("Stored: sha256:XYZ trailing")).toBe("sha256:XYZ"); }); it("extracts fingerprint from longer message string", () => { expect(parseStoredFingerprint("Certificate mismatch. Stored: sha256:OLD123")).toBe( "sha256:OLD123", ); }); }); describe("normalizeHostForCertCompare", () => { // Import the pure function directly — it must be exported for main.ts to // share it (see below) instead of keeping its own duplicate. let normalizeHostForCertCompare: typeof import("../../src/lib/ws").normalizeHostForCertCompare; beforeEach(async () => { const mod = await import("../../src/lib/ws"); normalizeHostForCertCompare = mod.normalizeHostForCertCompare; }); it("lowercases so a mixed-case saved host matches the Rust proxy's lowercased event host", () => { // This is exactly the guard main.ts's onCertMismatch/onCertFirstUse // handlers evaluate as `evt.host === normalizeHostForCertCompare(lastConnectHost)` // before resuming a connect (accept) or tearing down the live session // (reject). lastConnectHost is stored verbatim from the profile (e.g. // typed as "Example.COM:443"); evt.host arrives from tofu::cert_store_key // (src-tauri/src/tofu.rs), which always lowercases. Without lowercasing // here too, that guard silently fails for any uppercase host — worst // case, onReject's session teardown never fires and the user stays // connected to a server whose changed certificate they just rejected. const evtHost = "example.com"; const lastConnectHost = "Example.COM:443"; expect(evtHost === normalizeHostForCertCompare(lastConnectHost)).toBe(true); }); it("unwraps a portless bracketed IPv6 literal exactly as cert_store_key does", () => { // Mirrors the Rust assertions in // src-tauri/src/tofu.rs::cert_store_key_treats_bracketed_and_bare_ipv6_as_the_same_host. // A profile saved as "[2001:db8::1]" reaches the proxies bracketed, and // cert_store_key unwraps it (and its default-port form) before the // cert-tofu event is emitted; this mirror has to land on the same string // or main.ts's onAccept/onReject host guards silently never match. expect(normalizeHostForCertCompare("[2001:db8::1]")).toBe("2001:db8::1"); expect(normalizeHostForCertCompare("[2001:DB8::1]:443")).toBe("2001:db8::1"); // A non-default port keeps its brackets — a genuinely distinct pin key, // same as the plain "host:8443" case never collapsing to "host". expect(normalizeHostForCertCompare("[2001:db8::1]:8443")).toBe("[2001:db8::1]:8443"); }); it("strips :443 only as a trailing port, never mid-string", () => { // cert_store_key strips the DEFAULT port and nothing else. An unanchored // strip would eat the ":443" inside any other port that starts with it // (":4430", ":4433"), leaving main.ts's `evt.host === normalize(host)` // guard comparing against a host string the proxies never emit — so a // rejected certificate's teardown would silently never fire. expect(normalizeHostForCertCompare("example.com:443")).toBe("example.com"); expect(normalizeHostForCertCompare("example.com:4430")).toBe("example.com:4430"); }); }); describe("cert-tofu non-mismatch statuses", () => { let client: ReturnType; beforeEach(() => { vi.useFakeTimers(); mockInvoke.mockReset(); mockInvoke.mockResolvedValue(undefined); mockListen.mockClear(); eventHandlers.clear(); client = createWsClient(); }); afterEach(() => { client.disconnect(); vi.useRealTimers(); }); it("trusted_first_use status does not block reconnect", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Non-mismatch cert event emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:FIRST", status: "trusted_first_use", }); // State should still be connected (not disconnected) expect(client.getState()).toBe("connected"); // Verify mismatch listener was NOT called const mismatchEvents: unknown[] = []; client.onCertMismatch((e) => mismatchEvents.push(e)); emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:TRUSTED", status: "trusted", }); expect(mismatchEvents).toHaveLength(0); expect(client.getState()).toBe("connected"); }); }); describe("acceptCertFingerprint edge cases", () => { let client: ReturnType; beforeEach(() => { vi.useFakeTimers(); mockInvoke.mockReset(); mockInvoke.mockResolvedValue(undefined); mockListen.mockClear(); eventHandlers.clear(); client = createWsClient(); }); afterEach(() => { client.disconnect(); vi.useRealTimers(); }); it("calls Tauri invoke with correct command and args", async () => { // Must connect first so Tauri APIs are loaded client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); await client.acceptCertFingerprint("example.com", "sha256:NEWCERT"); expect(mockInvoke).toHaveBeenCalledWith("accept_cert_fingerprint", { host: "example.com", fingerprint: "sha256:NEWCERT", }); }); it("clears certMismatchBlock so reconnect works again", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Block with mismatch emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "mismatch", }); expect(client.getState()).toBe("disconnected"); // Accept fingerprint await client.acceptCertFingerprint("localhost:8443", "sha256:NEW"); // Reconnect should now work client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); expect(client.getState()).toBe("connecting"); }); }); describe("disconnect resets certMismatchBlock", () => { let client: ReturnType; beforeEach(() => { vi.useFakeTimers(); mockInvoke.mockReset(); mockInvoke.mockResolvedValue(undefined); mockListen.mockClear(); eventHandlers.clear(); client = createWsClient(); }); afterEach(() => { client.disconnect(); vi.useRealTimers(); }); it("clears certMismatchBlock on intentional disconnect", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); emitTauriEvent( "ws-message", JSON.stringify({ type: "auth_ok", payload: { user: { id: 1, username: "a", avatar: null, role: "admin" }, server_name: "S", motd: "", }, }), ); // Set cert mismatch block emitTauriEvent("cert-tofu", { host: "localhost:8443", fingerprint: "sha256:NEW", status: "mismatch", }); // Intentional disconnect should clear the block client.disconnect(); // Now reconnect should work (certMismatchBlock was cleared) mockInvoke.mockClear(); client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); expect(client.getState()).toBe("connecting"); }); });