mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes * test(server): api/constants — test-audit 2026-08-19 fixes * test(server): api/middleware — test-audit 2026-08-19 fixes * test(server): api/waf — test-audit 2026-08-19 fixes * test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes * test(server): db/session/expiry/test — test-audit 2026-08-19 fixes * test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes * test(server): updater/download — test-audit 2026-08-19 fixes * test(server): ws/handlers_command — test-audit 2026-08-19 fixes * test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes * test(server): ws/hub/events — test-audit 2026-08-19 fixes * test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes * test(server): ws/voice/controls — test-audit 2026-08-19 fixes * test(server): ws/voice/join — test-audit 2026-08-19 fixes * test(server): ws/voice/moderation — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes * test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes * test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes * test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes * test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes * test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes * test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes * test(client): keep tests/browser out of the jsdom suite and run it in CI * test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker) * docs: test audit 2026-08-19 — findings, fixes, measured baselines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(graph): refresh the knowledge graph after the 2026-08-19 test audit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1052 lines
30 KiB
TypeScript
1052 lines
30 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import type { ConnectionState } from "../../src/lib/ws";
|
|
|
|
// vi.mock is hoisted per file; the factories resolve to the shared handles
|
|
// exported from ./helpers/ws-mocks (see that module's doc comment).
|
|
vi.mock("@tauri-apps/api/core", async () => ({
|
|
invoke: (await import("./helpers/ws-mocks")).mockInvoke,
|
|
}));
|
|
|
|
vi.mock("@tauri-apps/api/event", async () => ({
|
|
listen: (await import("./helpers/ws-mocks")).mockListen,
|
|
}));
|
|
|
|
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
|
|
import { createWsClient, toConnectionStatus } from "../../src/lib/ws";
|
|
|
|
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[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
// "pong" is not part of the ServerMessage union — the transport eats it
|
|
// before dispatch — so the registry has to be reached through a cast to
|
|
// prove nothing arrives. The frame carries a payload on purpose: a
|
|
// payload-less pong is also dropped by the "missing type or payload"
|
|
// guard further down, which would hide a broken early return.
|
|
const onPong = vi.fn();
|
|
(client.on as unknown as (t: string, l: () => void) => () => void)("pong", onPong);
|
|
|
|
emitTauriEvent("ws-message", JSON.stringify({ type: "pong", payload: {} }));
|
|
|
|
expect(onPong).not.toHaveBeenCalled();
|
|
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("_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("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);
|
|
// Exactly ON the limit, not merely under it — the guard drops only what is
|
|
// strictly OVER, so a frame one byte short would pass either way and prove
|
|
// nothing about the boundary.
|
|
expect(exactJson.length).toBe(limit);
|
|
|
|
emitTauriEvent("ws-message", exactJson);
|
|
expect(messages).toHaveLength(1);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("does not drop an oversized `ready` frame — the handshake payload has no seq and no retry path (OC-0160)", async () => {
|
|
const limit = 200;
|
|
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const readyMessages: unknown[] = [];
|
|
client.on("ready", (p) => readyMessages.push(p));
|
|
|
|
const msg = {
|
|
type: "ready",
|
|
payload: {
|
|
channels: [],
|
|
// Padding well past `limit` — a real `ready` grows unbounded with the
|
|
// server's member/channel/DM counts and carries no seq, so unlike a
|
|
// sequenced frame nothing ever re-requests it after a drop.
|
|
members: Array.from({ length: 20 }, (_, i) => ({ id: i, username: `user${i}` })),
|
|
voice_states: [],
|
|
roles: [],
|
|
},
|
|
};
|
|
const json = JSON.stringify(msg);
|
|
expect(json.length).toBeGreaterThan(limit);
|
|
|
|
emitTauriEvent("ws-message", json);
|
|
expect(readyMessages).toHaveLength(1);
|
|
});
|
|
|
|
it("does not drop an oversized `auth_ok` frame (handshake exempt from size limit, OC-0160)", async () => {
|
|
const limit = 100;
|
|
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const msg = {
|
|
type: "auth_ok",
|
|
payload: {
|
|
user: { id: 1, username: "a".repeat(limit), avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
};
|
|
const json = JSON.stringify(msg);
|
|
expect(json.length).toBeGreaterThan(limit);
|
|
|
|
emitTauriEvent("ws-message", json);
|
|
expect(client.getState()).toBe("connected");
|
|
});
|
|
|
|
it("still drops an oversized regular (non-handshake) frame (OC-0160 regression guard)", 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),
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
};
|
|
|
|
emitTauriEvent("ws-message", JSON.stringify(msg));
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
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("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");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|