mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
The http and ws proxies accepted ANY certificate on first use and silently pinned it, forwarding login credentials and the bearer token before the user ever saw the fingerprint — an on-path attacker at first contact captured them. The three proxies also duplicated the TLS verifier and TOFU logic verbatim. - Extract the shared verifier, cert-store helpers, and a pure `decide` function into src-tauri/src/tofu.rs (used by the http/ws/livekit proxies). - Split the trust decision from persistence: a first-use cert is no longer pinned or forwarded to. The proxy rejects (ws: Err; http: 502) and emits a cert-tofu "first_use" event; the only writer of a pin is the explicit accept_cert_fingerprint command. - Frontend: a global cert-tofu listener (active during the connect page's health checks, before any WS connect) surfaces an SSH-style first-use confirmation modal. On accept the fingerprint is pinned and the server re-checked; nothing is sent to an unconfirmed host. Closes security-scan F4 (http proxy) and F8 (ws proxy). Verified: client typecheck/lint/format clean, full unit suite 3311/3311 green (incl. new ws first-use routing + modal tests). Rust compiles in CI (cargo clippy) per the client CLAUDE.md; pure tofu logic covered by #[cfg(test)] unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3323 lines
92 KiB
TypeScript
3323 lines
92 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import type { ConnectionState } from "../../src/lib/ws";
|
|
|
|
// Mock Tauri APIs — vi.hoisted ensures availability when vi.mock runs
|
|
const { mockInvoke, mockListen, eventHandlers } = vi.hoisted(() => {
|
|
const handlers = new Map<string, Array<(e: { payload: unknown }) => void>>();
|
|
return {
|
|
mockInvoke: vi.fn(),
|
|
mockListen: vi.fn(async (event: string, handler: (e: { payload: unknown }) => void) => {
|
|
if (!handlers.has(event)) handlers.set(event, []);
|
|
handlers.get(event)!.push(handler);
|
|
return () => {
|
|
const arr = handlers.get(event);
|
|
if (arr) {
|
|
const idx = arr.indexOf(handler);
|
|
if (idx >= 0) arr.splice(idx, 1);
|
|
}
|
|
};
|
|
}),
|
|
eventHandlers: handlers,
|
|
};
|
|
});
|
|
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: mockInvoke,
|
|
}));
|
|
|
|
vi.mock("@tauri-apps/api/event", () => ({
|
|
listen: mockListen,
|
|
}));
|
|
|
|
// Mock crypto.randomUUID
|
|
vi.stubGlobal("crypto", {
|
|
randomUUID: () => "test-uuid-1234",
|
|
});
|
|
|
|
// Suppress console output
|
|
vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
// Import after mocks are set up
|
|
import { createWsClient, toConnectionStatus } from "../../src/lib/ws";
|
|
|
|
/** Simulate Tauri emitting an event to JS */
|
|
function emitTauriEvent(event: string, payload: unknown): void {
|
|
const handlers = eventHandlers.get(event);
|
|
if (handlers) {
|
|
for (const h of handlers) {
|
|
h({ payload });
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("WebSocket Client (Tauri proxy)", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("starts in disconnected state", () => {
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("transitions to connecting on connect", async () => {
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(states).toContain("connecting");
|
|
});
|
|
|
|
it("calls ws_connect with correct URL", async () => {
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", {
|
|
url: "wss://localhost:8443/api/v1/ws",
|
|
});
|
|
});
|
|
|
|
it("sends auth message when Rust reports open", async () => {
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Simulate Rust reporting connection open
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Should call ws_send with auth message
|
|
expect(mockInvoke).toHaveBeenCalledWith(
|
|
"ws_send",
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('"type":"auth"'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("transitions to connected on auth_ok", async () => {
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
payload: {
|
|
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
|
server_name: "Test",
|
|
motd: "Hello",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(states).toContain("connected");
|
|
});
|
|
|
|
it("dispatches messages to typed listeners", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (payload) => messages.push(payload));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 5,
|
|
user: { id: 1, username: "alex", avatar: null },
|
|
content: "Hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-03-14T10:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(messages).toHaveLength(1);
|
|
});
|
|
|
|
it("unsubscribe removes listener", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
const unsub = client.on("chat_message", (payload) => messages.push(payload));
|
|
unsub();
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 5,
|
|
user: { id: 1, username: "alex", avatar: null },
|
|
content: "Hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-03-14T10:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("auth_error does NOT trigger reconnect", async () => {
|
|
client.connect({ host: "localhost:8443", token: "bad-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authErrors: unknown[] = [];
|
|
client.on("auth_error", (payload) => authErrors.push(payload));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_error",
|
|
payload: { message: "Invalid token" },
|
|
}),
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
expect(authErrors).toHaveLength(1);
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("reconnects on unexpected close with backoff", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
// Simulate connection closed by Rust proxy
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
expect(states).toContain("reconnecting");
|
|
|
|
// After 1s backoff, should call ws_connect again
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything());
|
|
});
|
|
|
|
it("send returns correlation ID", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const id = client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
|
|
expect(id).toBe("test-uuid-1234");
|
|
});
|
|
|
|
it("drops oversized messages", async () => {
|
|
client.connect({
|
|
host: "localhost:8443",
|
|
token: "t",
|
|
maxMessageSizeBytes: 50,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
const bigData = JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "x".repeat(100),
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
});
|
|
|
|
emitTauriEvent("ws-message", bigData);
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("drops malformed JSON", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
emitTauriEvent("ws-message", "not-json{{{");
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("disconnect prevents reconnect", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
client.disconnect();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
describe("lastSeq tracking", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("should start with lastSeq = 0", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// When open fires, auth message should contain last_seq: 0
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
expect(authCall).toBeDefined();
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(0);
|
|
});
|
|
|
|
it("should update lastSeq from seq field in incoming messages", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Send auth_ok so we're connected
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
seq: 1,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Send a message with seq 42
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 42,
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "hi",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Now simulate a disconnect + reconnect to verify lastSeq was updated
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100); // backoff
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
expect(authCall).toBeDefined();
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(42);
|
|
});
|
|
|
|
it("should send last_seq in auth message on 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",
|
|
seq: 5,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect unexpectedly
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
expect(authCall).toBeDefined();
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(5);
|
|
});
|
|
|
|
it("should preserve lastSeq across auto-reconnects", 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: 10,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// First auto-reconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Receive more messages with higher seq
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
seq: 11,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 25,
|
|
payload: {
|
|
id: 2,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Second auto-reconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(2100); // 2nd attempt = 2s backoff
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(25);
|
|
});
|
|
|
|
it("should reset lastSeq to 0 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",
|
|
seq: 50,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Intentional disconnect (e.g. logout)
|
|
client.disconnect();
|
|
|
|
// Reconnect fresh
|
|
mockInvoke.mockClear();
|
|
client.connect({ host: "localhost:8443", token: "t2" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
expect(authCall).toBeDefined();
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("cert mismatch blocking", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
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("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);
|
|
});
|
|
});
|
|
|
|
describe("message handling edge cases", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("silently ignores pong messages", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
// pong has no payload listeners, but we verify no crash
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
emitTauriEvent("ws-message", JSON.stringify({ type: "pong" }));
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("drops messages with missing type", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
emitTauriEvent("ws-message", JSON.stringify({ payload: { data: "no type" } }));
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("drops messages with undefined payload", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
emitTauriEvent("ws-message", JSON.stringify({ type: "chat_message" }));
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("tracks highest seq number (ignores lower seq)", 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: 10,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// seq=50 then seq=30 — should keep 50
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 50,
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "hi",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 30,
|
|
payload: {
|
|
id: 2,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect and reconnect to verify lastSeq
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(50);
|
|
});
|
|
|
|
it("handles message without seq field (defaults to 0)", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
// no seq field
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "no seq",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(messages).toHaveLength(1);
|
|
});
|
|
|
|
it("dispatch logs when no listeners for message type", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Send a message with no listener registered — should log "no listeners"
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// No crash means the "no listeners" debug log path executed
|
|
expect(client.getState()).toBe("connected");
|
|
});
|
|
|
|
it("dispatch catches listener errors", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Register a listener that throws
|
|
client.on("chat_message", () => {
|
|
throw new Error("listener boom");
|
|
});
|
|
|
|
// Also register a second listener to verify it still runs
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "test",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Second listener should still receive the message
|
|
expect(messages).toHaveLength(1);
|
|
});
|
|
|
|
it("state listener errors are caught", async () => {
|
|
client.onStateChange(() => {
|
|
throw new Error("state listener boom");
|
|
});
|
|
|
|
// Should not crash
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(client.getState()).toBe("connecting");
|
|
});
|
|
|
|
it("ws-error event is logged without crash", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Emit a ws-error event
|
|
emitTauriEvent("ws-error", "Connection reset by peer");
|
|
|
|
// No crash expected
|
|
expect(client.getState()).toBe("connecting");
|
|
});
|
|
|
|
it("isReplaying returns false when not reconnecting", () => {
|
|
expect(client.isReplaying()).toBe(false);
|
|
});
|
|
|
|
it("_getWs returns null", () => {
|
|
expect(client._getWs()).toBeNull();
|
|
});
|
|
|
|
it("onStateChange unsubscribe works", async () => {
|
|
const states: ConnectionState[] = [];
|
|
const unsub = client.onStateChange((s) => states.push(s));
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(states.length).toBeGreaterThan(0);
|
|
|
|
const count = states.length;
|
|
unsub();
|
|
|
|
emitTauriEvent("ws-state", "open");
|
|
expect(states.length).toBe(count);
|
|
});
|
|
|
|
it("onCertMismatch unsubscribe works", async () => {
|
|
const events: unknown[] = [];
|
|
const unsub = client.onCertMismatch((evt) => events.push(evt));
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
unsub();
|
|
|
|
emitTauriEvent("cert-tofu", {
|
|
host: "localhost:8443",
|
|
fingerprint: "sha256:NEW",
|
|
status: "mismatch",
|
|
message: "Stored: sha256:OLD",
|
|
});
|
|
|
|
expect(events).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("reconnection dedup", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("deduplicates messages during reconnection replay", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Auth and get some messages to advance lastSeq
|
|
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(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 5,
|
|
id: "msg-5",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "original",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect unexpectedly
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// Wait for reconnect
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// During reconnect, replay dedup is active
|
|
expect(client.isReplaying()).toBe(true);
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
// Send a message during replay -- first occurrence passes
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 5,
|
|
id: "msg-5",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "original",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Send the SAME message ID again — should be deduped
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 5,
|
|
id: "msg-5",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "original",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Only the first occurrence should pass through
|
|
expect(messages).toHaveLength(1);
|
|
expect((messages[0] as { content: string }).content).toBe("original");
|
|
});
|
|
|
|
it("auth_ok and ready messages are not deduped during replay", 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: 5,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
expect(client.isReplaying()).toBe(true);
|
|
|
|
const authPayloads: unknown[] = [];
|
|
client.on("auth_ok", (p) => authPayloads.push(p));
|
|
|
|
// auth_ok during replay should NOT be deduped
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
seq: 6,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(authPayloads).toHaveLength(1);
|
|
// After auth_ok, replay dedup should be cleared
|
|
expect(client.isReplaying()).toBe(false);
|
|
});
|
|
|
|
it("dedup uses type:seq as key when message has no id", 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(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "presence",
|
|
seq: 10,
|
|
payload: { user_id: 1, status: "idle" },
|
|
}),
|
|
);
|
|
|
|
// Disconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const presences: unknown[] = [];
|
|
client.on("presence", (p) => presences.push(p));
|
|
|
|
// First presence during replay — passes through
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "presence",
|
|
seq: 10,
|
|
payload: { user_id: 1, status: "idle" },
|
|
}),
|
|
);
|
|
|
|
// Same type:seq — should be deduped
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "presence",
|
|
seq: 10,
|
|
payload: { user_id: 1, status: "idle" },
|
|
}),
|
|
);
|
|
|
|
// Different seq — should pass through
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "presence",
|
|
seq: 11,
|
|
payload: { user_id: 1, status: "online" },
|
|
}),
|
|
);
|
|
|
|
expect(presences).toHaveLength(2);
|
|
expect((presences[0] as { status: string }).status).toBe("idle");
|
|
expect((presences[1] as { status: string }).status).toBe("online");
|
|
});
|
|
|
|
it("dedup is not active for first connection (lastSeq=0)", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// First connect should NOT enable dedup
|
|
expect(client.isReplaying()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("heartbeat", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("sends heartbeat ping every 30 seconds after auth_ok", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mockInvoke.mockClear();
|
|
|
|
// Advance 30 seconds — should send a ping
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
|
|
const pingSends = mockInvoke.mock.calls.filter(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"ping"'),
|
|
);
|
|
expect(pingSends.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("stops heartbeat on 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",
|
|
seq: 1,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
client.disconnect();
|
|
mockInvoke.mockClear();
|
|
|
|
// No heartbeat should be sent after disconnect
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
const pingSends = mockInvoke.mock.calls.filter(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"ping"'),
|
|
);
|
|
expect(pingSends).toHaveLength(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("setState deduplication", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not notify listeners when state is already the same", async () => {
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// State is now "connecting". Count how many times "connecting" appeared.
|
|
const connectingCount = states.filter((s) => s === "connecting").length;
|
|
expect(connectingCount).toBe(1);
|
|
});
|
|
|
|
it("notifies listeners when state actually changes", async () => {
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Should have transitioned: connecting -> authenticating -> connected
|
|
expect(states).toContain("connecting");
|
|
expect(states).toContain("authenticating");
|
|
expect(states).toContain("connected");
|
|
});
|
|
});
|
|
|
|
describe("getReconnectDelay boundary and arithmetic", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("first reconnect delay is 1000ms (1000 * 2^0)", 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("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
|
|
// At 999ms, should NOT have reconnected yet
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsBefore).toHaveLength(0);
|
|
|
|
// At 1000ms total, should reconnect
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsAfter).toHaveLength(1);
|
|
});
|
|
|
|
it("second reconnect delay is 2000ms (1000 * 2^1)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// First drop + reconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
// Don't send auth_ok, so reconnectAttempt stays incremented
|
|
// Simulate another close immediately
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
mockInvoke.mockClear();
|
|
|
|
// Second attempt should have 2000ms delay
|
|
await vi.advanceTimersByTimeAsync(1999);
|
|
const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsBefore).toHaveLength(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsAfter).toHaveLength(1);
|
|
});
|
|
|
|
it("delay uses default 30000ms cap when maxReconnectDelayMs not set", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Simulate many drops to ramp up backoff
|
|
for (let i = 0; i < 10; i++) {
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
}
|
|
|
|
// After 10 attempts, uncapped delay would be 1000*2^10 = 1024000ms
|
|
// But it should be capped at 30000ms (default)
|
|
mockInvoke.mockClear();
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// Should reconnect within 30s (capped), not 1024s
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
const calls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe("handleMessage size boundary", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("accepts message exactly at size limit", async () => {
|
|
const limit = 200;
|
|
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
const msg = {
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
};
|
|
const json = JSON.stringify(msg);
|
|
// Pad content to make JSON exactly at limit
|
|
const padding = limit - json.length;
|
|
if (padding > 0) {
|
|
msg.payload.content = "x".repeat(padding);
|
|
}
|
|
const exactJson = JSON.stringify(msg);
|
|
// Ensure it is exactly at limit (not over)
|
|
expect(exactJson.length).toBeLessThanOrEqual(limit);
|
|
|
|
emitTauriEvent("ws-message", exactJson);
|
|
expect(messages.length).toBeGreaterThanOrEqual(0); // should not crash
|
|
});
|
|
|
|
it("drops message one byte over size limit", async () => {
|
|
const limit = 100;
|
|
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
const msg = {
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "x".repeat(limit), // guarantees over limit
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
};
|
|
|
|
emitTauriEvent("ws-message", JSON.stringify(msg));
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("uses default 1MB limit when maxMessageSizeBytes not configured", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
// Message under 1MB should pass
|
|
const smallMsg = JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "small",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
});
|
|
emitTauriEvent("ws-message", smallMsg);
|
|
expect(messages).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("seq tracking boundary conditions", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does NOT update lastSeq when seq equals current lastSeq (> not >=)", 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: 10,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Send message with same seq=10 — should NOT change lastSeq
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 10,
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "same seq",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Verify lastSeq is still 10 via reconnect auth message
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(10);
|
|
});
|
|
|
|
it("treats non-number seq as 0", 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: 5,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Send message with string seq — treated as 0, should not reduce lastSeq
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: "not-a-number",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "bad seq",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authCall = mockInvoke.mock.calls.find(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
|
|
expect(authMsg.payload.last_seq).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe("scheduleReconnect guard clauses", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not reconnect when intentionalClose is true (disconnect called)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Intentional disconnect sets intentionalClose=true
|
|
client.disconnect();
|
|
mockInvoke.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(reconnects).toHaveLength(0);
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("does not reconnect when certMismatchBlock is true", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Trigger cert mismatch
|
|
emitTauriEvent("cert-tofu", {
|
|
host: "localhost:8443",
|
|
fingerprint: "sha256:NEW",
|
|
status: "mismatch",
|
|
});
|
|
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(reconnects).toHaveLength(0);
|
|
});
|
|
|
|
it("reconnect timer callback bails out safely when config is cleared", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Unexpected close schedules reconnect.
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// Simulate config being cleared before timer callback executes.
|
|
client.disconnect();
|
|
mockInvoke.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(reconnects).toHaveLength(0);
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
describe("cert-tofu non-mismatch statuses", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
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("dedup eviction when exceeding MAX_DEDUP_SIZE", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("evicts oldest entry when dedup set exceeds 1000 entries", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Get past lastSeq > 0 condition
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 100,
|
|
payload: {
|
|
id: 99,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "bump seq",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect to trigger dedup mode
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
expect(client.isReplaying()).toBe(true);
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
// Send 1002 unique messages to trigger eviction (MAX_DEDUP_SIZE = 1000)
|
|
for (let i = 0; i < 1002; i++) {
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 101 + i,
|
|
id: `msg-${i}`,
|
|
payload: {
|
|
id: i,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: `msg ${i}`,
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
// All 1002 should have been dispatched (first occurrence of each)
|
|
expect(messages).toHaveLength(1002);
|
|
|
|
// Now re-send the very first message (msg-0) — it was evicted, so it should pass again
|
|
const countBefore = messages.length;
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 101,
|
|
id: "msg-0",
|
|
payload: {
|
|
id: 0,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "msg 0",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
expect(messages).toHaveLength(countBefore + 1);
|
|
});
|
|
});
|
|
|
|
describe("auth_error during reconnection replay", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("auth_error is not deduped during replay and stops 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",
|
|
seq: 5,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
expect(client.isReplaying()).toBe(true);
|
|
|
|
const errors: unknown[] = [];
|
|
client.on("auth_error", (p) => errors.push(p));
|
|
|
|
// auth_error during replay — should NOT be deduped
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_error",
|
|
payload: { message: "Token expired" },
|
|
}),
|
|
);
|
|
|
|
expect(errors).toHaveLength(1);
|
|
expect(client.getState()).toBe("disconnected");
|
|
|
|
// Should not reconnect after auth_error
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(reconnects).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("wsGeneration stale listener guard", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("ignores events from stale generation after new connect()", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Capture the handlers registered in the first connect
|
|
const oldMsgHandlers = [...(eventHandlers.get("ws-message") ?? [])];
|
|
const oldStateHandlers = [...(eventHandlers.get("ws-state") ?? [])];
|
|
|
|
// Start a new connection (increments wsGeneration, cleans up old handlers)
|
|
client.connect({ host: "localhost:8443", token: "t2" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
// If any old handlers survived cleanup, calling them should be a no-op
|
|
// because gen !== wsGeneration
|
|
for (const h of oldMsgHandlers) {
|
|
h({
|
|
payload: JSON.stringify({
|
|
type: "auth_ok",
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
for (const h of oldStateHandlers) {
|
|
h({ payload: "open" });
|
|
}
|
|
|
|
// State should NOT have changed to connected from stale handlers
|
|
expect(states).not.toContain("connected");
|
|
});
|
|
});
|
|
|
|
describe("acceptCertFingerprint edge cases", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
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("heartbeat proxyOpen guard", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not send ping when proxyOpen is false (connection dropped mid-heartbeat)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Heartbeat started. Now close the proxy (sets proxyOpen=false)
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// Clear mocks and advance past heartbeat interval
|
|
mockInvoke.mockClear();
|
|
|
|
// The heartbeat was stopped by close handler, so no pings should fire
|
|
await vi.advanceTimersByTimeAsync(35_000);
|
|
|
|
const pings = mockInvoke.mock.calls.filter(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"ping"'),
|
|
);
|
|
expect(pings).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("disconnect resets certMismatchBlock", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("auth_ok during reconnection logs reconnect info", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("resets reconnectAttempt to 0 after successful reconnect auth_ok", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// First drop
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100); // 1s backoff
|
|
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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Second drop — if reconnectAttempt was reset, delay is back to 1s not 2s
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
|
|
// At 1s should reconnect (not 2s)
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
const calls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("dispatch with no listeners for type", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not crash when dispatching to type with empty listener set", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Register and immediately unregister a listener
|
|
const unsub = client.on("chat_message", () => {});
|
|
unsub();
|
|
|
|
// Now dispatch a message to that type — empty set
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "test",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// No crash
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("dispatches message with id to listener", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const receivedIds: (string | undefined)[] = [];
|
|
client.on("chat_message", (_payload, id) => {
|
|
receivedIds.push(id);
|
|
});
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
id: "correlation-123",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "test",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(receivedIds).toEqual(["correlation-123"]);
|
|
});
|
|
});
|
|
|
|
describe("on() creates Set for new type", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("creates a listener set for a type that has never been registered", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const results: unknown[] = [];
|
|
client.on("presence", (p) => results.push(p));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "presence",
|
|
payload: { user_id: 1, status: "online" },
|
|
}),
|
|
);
|
|
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
|
|
it("multiple listeners on same type all receive messages", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const results1: unknown[] = [];
|
|
const results2: unknown[] = [];
|
|
client.on("typing", (p) => results1.push(p));
|
|
client.on("typing", (p) => results2.push(p));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "typing",
|
|
payload: { channel_id: 1, user_id: 1, username: "a" },
|
|
}),
|
|
);
|
|
|
|
expect(results1).toHaveLength(1);
|
|
expect(results2).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("send envelope format", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("wraps message with id and serializes to JSON", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mockInvoke.mockClear();
|
|
|
|
client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hello", reply_to: null, attachments: [] },
|
|
});
|
|
|
|
const sendCall = mockInvoke.mock.calls.find((c) => c[0] === "ws_send");
|
|
expect(sendCall).toBeDefined();
|
|
|
|
const sent = JSON.parse((sendCall![1] as { message: string }).message);
|
|
expect(sent.type).toBe("chat_send");
|
|
expect(sent.id).toBe("test-uuid-1234");
|
|
expect(sent.payload.channel_id).toBe(1);
|
|
expect(sent.payload.content).toBe("hello");
|
|
expect(sent.payload.reply_to).toBeNull();
|
|
expect(sent.payload.attachments).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("connect when Tauri APIs unavailable", () => {
|
|
it("falls back to disconnected when ensureTauriApis fails", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
// Create a fresh client that will try to load Tauri APIs fresh
|
|
// The mock is already set up to resolve, so we need to simulate unavailability
|
|
// by making tauriInvoke null after ensureTauriApis
|
|
const origInvoke = mockInvoke;
|
|
|
|
// Temporarily clear the mock module to simulate Tauri not available
|
|
// We test this indirectly: if ws_connect is never called but state
|
|
// goes back to disconnected, the guard worked
|
|
const client2 = createWsClient();
|
|
const states: ConnectionState[] = [];
|
|
client2.onStateChange((s) => states.push(s));
|
|
|
|
client2.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// With the mock in place, it should proceed normally
|
|
expect(states).toContain("connecting");
|
|
|
|
client2.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe("cleanupEventListeners edge cases", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
// Save original mockListen implementation to restore after override tests
|
|
let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R
|
|
? R
|
|
: never;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
originalMockListenImpl = mockListen.getMockImplementation()!;
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
// Restore the original mockListen implementation so later tests work
|
|
mockListen.mockImplementation(originalMockListenImpl!);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("handles unsub functions that return rejected promises", async () => {
|
|
// Override mockListen to return an unsub that returns a rejected promise
|
|
mockListen.mockImplementation(
|
|
async (_event: string, _handler: (e: { payload: unknown }) => void) => {
|
|
return () => {
|
|
return Promise.reject(new Error("resource invalidated"));
|
|
};
|
|
},
|
|
);
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Disconnect triggers cleanupEventListeners — should not crash
|
|
client.disconnect();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("handles unsub functions that throw synchronously", async () => {
|
|
mockListen.mockImplementation(
|
|
async (_event: string, _handler: (e: { payload: unknown }) => void) => {
|
|
return () => {
|
|
throw new Error("sync unsub error");
|
|
};
|
|
},
|
|
);
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Should not crash
|
|
client.disconnect();
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
describe("dedup does not filter auth_ok, auth_error, or ready during replay", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("ready message is not deduped during replay", 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: 5,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 10,
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "hi",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect and reconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
expect(client.isReplaying()).toBe(true);
|
|
|
|
const readyPayloads: unknown[] = [];
|
|
client.on("ready", (p) => readyPayloads.push(p));
|
|
|
|
// Send ready during replay BEFORE auth_ok — should NOT be deduped
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "ready",
|
|
seq: 11,
|
|
payload: {
|
|
channels: [],
|
|
members: [],
|
|
voice_states: [],
|
|
roles: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(readyPayloads).toHaveLength(1);
|
|
|
|
// Send ready again with same seq — ready is exempt from dedup, so it passes
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "ready",
|
|
seq: 11,
|
|
payload: {
|
|
channels: [],
|
|
members: [],
|
|
voice_states: [],
|
|
roles: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(readyPayloads).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("send edge cases", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("send when not connected does not crash (logs warning)", () => {
|
|
// Client is disconnected — send should warn but not crash
|
|
const id = client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
|
|
expect(id).toBe("test-uuid-1234");
|
|
});
|
|
|
|
it("ws_connect failure triggers reconnect", async () => {
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_connect") throw new Error("connection refused");
|
|
return undefined;
|
|
});
|
|
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Should attempt reconnect after failure
|
|
expect(states).toContain("reconnecting");
|
|
});
|
|
|
|
it("reconnect with successful auth_ok resets reconnect attempt counter", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Drop connection
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// First reconnect (1s backoff)
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
seq: 2,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Drop again
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// If reconnect counter was reset, delay should be back to 1s (not 2s)
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
|
|
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(reconnects.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("ws_send rejection is caught without crash", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Make ws_send reject
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_send") throw new Error("send failed");
|
|
return undefined;
|
|
});
|
|
|
|
// Send should not crash despite ws_send rejection
|
|
client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
|
|
// Flush promise to trigger the catch
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(client.getState()).toBe("connected");
|
|
});
|
|
|
|
it("onSendFailure fires with NETWORK when ws_send hits backpressure (channel full)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped");
|
|
return undefined;
|
|
});
|
|
|
|
const failures: Array<{ id: string; code: string }> = [];
|
|
client.onSendFailure((id, code) => failures.push({ id, code }));
|
|
|
|
const id = client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(failures).toEqual([{ id, code: "NETWORK" }]);
|
|
});
|
|
|
|
it("onSendFailure fires with OFFLINE when ws_send reports the channel closed", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_send") throw new Error("ws_send: channel closed");
|
|
return undefined;
|
|
});
|
|
|
|
const failures: Array<{ id: string; code: string }> = [];
|
|
client.onSendFailure((id, code) => failures.push({ id, code }));
|
|
|
|
const id = client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(failures).toEqual([{ id, code: "OFFLINE" }]);
|
|
});
|
|
|
|
it("onSendFailure fires with OFFLINE when sending while the proxy is not open", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Drop the proxy: subsequent sends take the not-open early return.
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
const failures: Array<{ id: string; code: string }> = [];
|
|
client.onSendFailure((id, code) => failures.push({ id, code }));
|
|
|
|
const id = client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
// The early-return notification is deferred a microtask so callers can
|
|
// register the id (optimistic row) before the failure lands.
|
|
expect(failures).toEqual([]);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(failures).toEqual([{ id, code: "OFFLINE" }]);
|
|
});
|
|
|
|
it("heartbeat ping failures do not fire onSendFailure (no envelope id)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped");
|
|
return undefined;
|
|
});
|
|
|
|
const failures: Array<{ id: string; code: string }> = [];
|
|
client.onSendFailure((id, code) => failures.push({ id, code }));
|
|
|
|
// Let the 30s heartbeat fire (and its ws_send reject).
|
|
await vi.advanceTimersByTimeAsync(30_100);
|
|
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it("onSendFailure unsubscribe works", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped");
|
|
return undefined;
|
|
});
|
|
|
|
const failures: Array<{ id: string; code: string }> = [];
|
|
const unsub = client.onSendFailure((id, code) => failures.push({ id, code }));
|
|
unsub();
|
|
|
|
client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(failures).toEqual([]);
|
|
});
|
|
|
|
it("ws_disconnect error is ignored during disconnectProxy", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Make ws_disconnect throw
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_disconnect") throw new Error("disconnect failed");
|
|
return undefined;
|
|
});
|
|
|
|
// Disconnect should not crash
|
|
client.disconnect();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("reconnect delay is capped by maxReconnectDelayMs", async () => {
|
|
client.connect({
|
|
host: "localhost:8443",
|
|
token: "t",
|
|
maxReconnectDelayMs: 5000,
|
|
});
|
|
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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Force multiple reconnect attempts to ramp up backoff
|
|
for (let i = 0; i < 5; i++) {
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(10_000); // well past any backoff
|
|
emitTauriEvent("ws-state", "open");
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
seq: i + 2,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
// At this point, the reconnect delay should be capped at 5000ms
|
|
// The fact that the loop completed without hanging proves capping works
|
|
expect(client.getState()).toBe("connected");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Listener registry mechanics (no Tauri connection needed)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("listener registry mechanics (on/off/dispatch)", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("on() registers a listener and returns an unsubscribe function", () => {
|
|
const listener = vi.fn();
|
|
const unsub = client.on("chat_message", listener);
|
|
expect(typeof unsub).toBe("function");
|
|
});
|
|
|
|
it("off via returned unsubscribe removes a specific listener", async () => {
|
|
// Connect so we can dispatch messages through the proxy
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const calls: string[] = [];
|
|
const listenerA = () => calls.push("A");
|
|
const listenerB = () => calls.push("B");
|
|
|
|
client.on("chat_message", listenerA);
|
|
const unsubB = client.on("chat_message", listenerB);
|
|
|
|
// Remove only B
|
|
unsubB();
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "test",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(calls).toEqual(["A"]);
|
|
});
|
|
|
|
it("multiple listeners on the same event type all get called", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const calls: string[] = [];
|
|
client.on("chat_message", () => calls.push("first"));
|
|
client.on("chat_message", () => calls.push("second"));
|
|
client.on("chat_message", () => calls.push("third"));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "test",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(calls).toEqual(["first", "second", "third"]);
|
|
});
|
|
|
|
it("listener removal mid-dispatch does not crash", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const calls: string[] = [];
|
|
let unsubSelf: (() => void) | null = null;
|
|
|
|
// This listener unsubscribes itself when called
|
|
unsubSelf = client.on("chat_message", () => {
|
|
calls.push("self-removing");
|
|
unsubSelf!();
|
|
});
|
|
|
|
// Second listener should still be called
|
|
client.on("chat_message", () => calls.push("survivor"));
|
|
|
|
const msgJson = JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "test",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
});
|
|
|
|
// First dispatch — self-removing listener fires then removes itself
|
|
emitTauriEvent("ws-message", msgJson);
|
|
expect(calls).toContain("self-removing");
|
|
expect(calls).toContain("survivor");
|
|
|
|
// Second dispatch — only survivor should fire
|
|
calls.length = 0;
|
|
emitTauriEvent("ws-message", msgJson);
|
|
expect(calls).toEqual(["survivor"]);
|
|
});
|
|
|
|
it("unknown event type dispatch does not throw", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Dispatch a completely unknown event type — should not crash
|
|
expect(() => {
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "totally_unknown_event",
|
|
payload: { foo: "bar" },
|
|
}),
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("error boundary: throwing listener does not prevent next listener from running", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const received: string[] = [];
|
|
|
|
client.on("chat_message", () => {
|
|
throw new Error("first listener explodes");
|
|
});
|
|
client.on("chat_message", (payload) => {
|
|
received.push((payload as { content: string }).content);
|
|
});
|
|
client.on("chat_message", () => {
|
|
throw new Error("third listener also explodes");
|
|
});
|
|
client.on("chat_message", (payload) => {
|
|
received.push("fourth:" + (payload as { content: string }).content);
|
|
});
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Both non-throwing listeners should have received the message
|
|
expect(received).toEqual(["hello", "fourth:hello"]);
|
|
});
|
|
});
|
|
|
|
describe("toConnectionStatus", () => {
|
|
it("maps the internal 5-state machine onto the UX-facing 3-state status", () => {
|
|
expect(toConnectionStatus("connected")).toBe("connected");
|
|
expect(toConnectionStatus("disconnected")).toBe("disconnected");
|
|
// Mid-retry states must read as "reconnecting", not "disconnected" —
|
|
// a reconnect cycle passes through connecting/authenticating.
|
|
expect(toConnectionStatus("reconnecting")).toBe("reconnecting");
|
|
expect(toConnectionStatus("connecting")).toBe("reconnecting");
|
|
expect(toConnectionStatus("authenticating")).toBe("reconnecting");
|
|
});
|
|
});
|