Files
OwnCord/Client/tauri-client/tests/unit/ws-reconnect.test.ts
T
J3vbandClaude Fable 5 03fcb7d518 fix: execute the 2026-08-19 audit fix order (docs refresh + five FRAGILE fixes) (#1396)
* docs(plans): phased remediation plan for the 2026-08-19 audit

Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per
finding group, statuses updated in place as phases land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* test(client): give the renderWindow-breaker test its own timeout (audit F-5)

30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a
loaded runner; the test timed out once under CI-like load and passes in
isolation, so it now carries an explicit 20s budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10)

schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL
(matching 030's rebuild), index inventory rewritten from cumulative migration
state, writer/reader pool split described, default-roles table made a
consistent post-migration snapshot, dbgen preamble updated.

protocol.md: DM chat events documented as sequenced/ring-buffered/replayable
(they are), plugin_broadcast seq flipped to Yes, retry_after claim removed
(no WS error carries it), the five enforced-but-documented-as-None rate
limits added (channel_focus, mark_read, call_decline, chat_command, ping),
E2EE announce/offer budgets corrected incl. the per-target inner cap,
BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/
roles field lists completed, member_join top-level status documented.

api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP
limiter and host:port livekit_url, error-code table now matches emitted codes
(INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap
exemptions listed, identity_public_key documented on PATCH /users/me, plugin
endpoints' plain-text errors + X-Plugin-Runtime header documented, /health
503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply
restart-conflict 409s added.

Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes
(buildReady comment, e2e spec-count comments, logctx stray word, three plan
status headers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16)

Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6
session-cap eviction failure in CreateSession (db/auth_queries.go), and the
channel_focus read-state write failure (service/channel.go) all discarded
their errors with no trace — a brute-force lockout could silently fail to
survive a restart. In-memory behavior is unchanged (warn-and-continue); the
lockout write paths are pinned by tests mirroring OC-0061's load-path test.
The session-cap and read-state sites are log-only additions on seams the
existing suites already exercise on the success path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1)

The block gate ran only at voice_join and voluntary voice_token_refresh, so
a blocked user already in the shared 1:1 DM call kept their session
indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser
now severs the call through the dmVoiceEvictor capability handleCloseDM
already exercises, using a new find-only FindDMChannelIDBetween lookup
(sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group
DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler
tests: shared-DM eviction, no-DM no-op, group-only no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(ws): close the role-reassignment/handshake race (audit F-2)

A role reassignment landing mid-handshake was invisible for the socket's
whole life: both handshake paths resolved permissions from the auth-time
c.user snapshot, revokeUnreadableChannels early-returns for a user not yet
in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a
reconnect replaced the client.

Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and
role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the
resume-fallback path re-reads the role once more after registerNow and runs
the revocation pass when it moved, so the reassignment-vs-registration
orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the
live client immediately before acting, mirroring RefreshChannelVisibility.
Pinned by four tests driving real WS handshakes through the existing race
hooks plus a new pre-register/pre-act hook pair; ws suite green under the
default and deadlock builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* refactor(client): remove the inert replay-dedup machinery (audit F-6)

The server writes auth_ok before the replay burst, so replayDedup — created
on socket-open and cleared when auth_ok is processed — could never be active
for a real replayed frame, and the dispatcher's isReplaying() unread gates
never fired. Their no-op behavior is the correct behavior (a buffer/db
resume has no ready payload, so replayed frames must count as unread), so
the machinery, the gates, and the misleading comments are removed rather
than repaired. The pinning tests injected replay frames in an order a
spec-compliant server never produces; they are replaced by a test pinning
the real contract (frames after auth_ok are dispatched verbatim; duplicate
handling belongs to the stores). Client suite green: 5036/5036.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* docs(plans): mark remediation phases 1-6 done

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(ws): nolint the context-less revoke call golangci-lint flags

revokeUnreadableChannels takes no context by design (admin HubBroadcaster
interface); annotate the one call site inside a ctx-taking function, matching
the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 03:49:13 +02:00

843 lines
23 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock is hoisted per file; the factories resolve to the shared handles
// exported from ./helpers/ws-mocks (see that module's doc comment).
vi.mock("@tauri-apps/api/core", async () => ({
invoke: (await import("./helpers/ws-mocks")).mockInvoke,
}));
vi.mock("@tauri-apps/api/event", async () => ({
listen: (await import("./helpers/ws-mocks")).mockListen,
}));
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
import { createWsClient } from "../../src/lib/ws";
describe("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("resets lastSeq when auth_ok reports replay_source: none (full resync)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
// Initial connect — lastSeq climbs to 5000 via live traffic (no seq on
// auth_ok itself, matching the real server: h.buildAuthOK never sets a
// top-level "seq" field).
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
replay_source: "none",
},
}),
);
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
seq: 5000,
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",
},
}),
);
// Socket drops; server restarted meanwhile with its seq counter reset
// (event_persistence.enabled=false), so the reconnect's replay tier is a
// full resync — auth_ok comes back with replay_source: "none" again.
emitTauriEvent("ws-state", "closed");
await vi.advanceTimersByTimeAsync(1100);
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: "",
replay_source: "none",
},
}),
);
// A subsequent drop must send last_seq=0 (adopting the server's new
// epoch), not the stale 5000 watermark from the old epoch.
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"'),
);
expect(authCall).toBeDefined();
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
expect(authMsg.payload.last_seq).toBe(0);
});
it("does NOT reset lastSeq when auth_ok reports replay_source: buffer/db (real resume)", 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: "",
replay_source: "none",
},
}),
);
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",
},
}),
);
emitTauriEvent("ws-state", "closed");
await vi.advanceTimersByTimeAsync(1100);
emitTauriEvent("ws-state", "open");
// A real resume from the ring buffer/DB — must NOT reset lastSeq.
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
replay_source: "buffer",
},
}),
);
emitTauriEvent("ws-state", "closed");
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(2100);
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(42);
});
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("reconnection replay handling", () => {
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("dispatches replay-burst frames arriving after auth_ok verbatim (no client-side dedup layer)", 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: "chat_message",
seq: 5,
id: "msg-5",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "before disconnect",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
emitTauriEvent("ws-state", "closed");
await vi.advanceTimersByTimeAsync(1100);
emitTauriEvent("ws-state", "open");
// The server writes auth_ok BEFORE the replay burst
// (reconnectWriteReplay), so replayed frames land in the connected state
// exactly like live ones. The ws layer applies them verbatim — the old
// replayDedup machinery keyed off a pre-auth_ok window that never exists
// against a spec-compliant server and was removed (audit-2026-08-19
// F-6); duplicate handling belongs to the stores.
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 5,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
replay_source: "buffer",
},
}),
);
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
const replayFrame = JSON.stringify({
type: "chat_message",
seq: 6,
id: "msg-6",
payload: {
id: 2,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "replayed",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
});
emitTauriEvent("ws-message", replayFrame);
emitTauriEvent("ws-message", replayFrame);
expect(messages).toHaveLength(2);
});
});
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("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 after a reconnect handshake stops reconnection", 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");
const errors: unknown[] = [];
client.on("auth_error", (p) => errors.push(p));
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("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);
});
});