mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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>
This commit is contained in:
@@ -567,17 +567,18 @@ export function wireDispatcher(
|
||||
const isOwnMessage = currentUserId !== null && payload.user.id === currentUserId;
|
||||
|
||||
// Increment channel-level unread for non-active, non-own-message channels.
|
||||
// Skip during reconnection replay to avoid inflating counts — the
|
||||
// server's ready payload already contains accurate unread_count values.
|
||||
// DM channel IDs are not in channelsStore (they use dmStore), so
|
||||
// incrementUnread is a no-op for DMs, but the own-message guard is
|
||||
// Replayed frames increment unread counts like live ones — the burst
|
||||
// is exactly the messages missed while away (a full-ready resume sends
|
||||
// no burst at all; ready's unread_count values are authoritative
|
||||
// there). DM channel IDs are not in channelsStore (they use dmStore),
|
||||
// so incrementUnread is a no-op for DMs, but the own-message guard is
|
||||
// applied here for defence-in-depth.
|
||||
const isMention = highlightsCurrentUser(payload.content, {
|
||||
mentions: payload.mentions,
|
||||
mentionsEveryone: payload.mentions_everyone,
|
||||
});
|
||||
|
||||
if (payload.channel_id !== activeId && !isOwnMessage && !ws.isReplaying()) {
|
||||
if (payload.channel_id !== activeId && !isOwnMessage) {
|
||||
incrementUnread(payload.channel_id);
|
||||
// A mention is an unread too — the mention badge just outranks it.
|
||||
if (isMention) {
|
||||
@@ -586,10 +587,10 @@ export function wireDispatcher(
|
||||
}
|
||||
|
||||
// Update DM store last message if this message belongs to a DM channel.
|
||||
// Skip unread increment for own messages, currently focused DM, and replay.
|
||||
// Skip unread increment for own messages and the currently focused DM.
|
||||
if (isDm) {
|
||||
const isDmActive = payload.channel_id === activeId;
|
||||
if (isOwnMessage || isDmActive || ws.isReplaying()) {
|
||||
if (isOwnMessage || isDmActive) {
|
||||
// Update last message preview but don't increment unread count.
|
||||
updateDmLastMessagePreview(
|
||||
payload.channel_id,
|
||||
@@ -609,13 +610,13 @@ export function wireDispatcher(
|
||||
}
|
||||
|
||||
// Fire desktop notification, taskbar flash, and sound — but not for a
|
||||
// reconnect's replayed burst. ws.isReplaying() cannot gate this the way
|
||||
// it gates the unread counter above: ws.ts clears it as soon as auth_ok
|
||||
// is processed, before the replay burst itself even arrives. A replay
|
||||
// frame's timestamp instead predates the reconnect handshake that
|
||||
// preceded it, unlike a genuinely new live message — compared in
|
||||
// server-clock terms (see serverClockSkewMs above) so a lagging or
|
||||
// skewed server clock cannot make a live message look like a replay.
|
||||
// reconnect's replayed burst. No connection-state flag can gate this:
|
||||
// the server writes auth_ok before the burst, so by the time replayed
|
||||
// frames arrive the client is already "connected". A replay frame's
|
||||
// timestamp instead predates the reconnect handshake that preceded it,
|
||||
// unlike a genuinely new live message — compared in server-clock terms
|
||||
// (see serverClockSkewMs above) so a lagging or skewed server clock
|
||||
// cannot make a live message look like a replay.
|
||||
// The wall-clock window additionally bounds a cold (never-sampled)
|
||||
// skew's damage — see REPLAY_GATE_WINDOW_MS.
|
||||
const isReplayFrame =
|
||||
|
||||
@@ -159,11 +159,6 @@ export function createWsClient() {
|
||||
let proxyOpen = false;
|
||||
let lastSeq = 0;
|
||||
|
||||
// Deduplication cache for reconnection replay.
|
||||
// Active when reconnecting (reconnectAttempt > 0) until auth_ok.
|
||||
let replayDedup: Set<string> | null = null;
|
||||
const MAX_DEDUP_SIZE = 1000;
|
||||
|
||||
// Tauri event unsubscribe functions
|
||||
const eventUnsubs: Array<() => void> = [];
|
||||
|
||||
@@ -302,28 +297,6 @@ export function createWsClient() {
|
||||
|
||||
log.debug("WS ←", { type: msg.type, id: msg.id });
|
||||
|
||||
// Deduplication during reconnection replay
|
||||
if (
|
||||
replayDedup !== null &&
|
||||
msg.type !== "auth_ok" &&
|
||||
msg.type !== "auth_error" &&
|
||||
msg.type !== "ready"
|
||||
) {
|
||||
const dedupKey = msg.id ?? `${msg.type}:${seq}`;
|
||||
if (replayDedup.has(dedupKey)) {
|
||||
log.debug("Dedup: skipping duplicate message", { type: msg.type, key: dedupKey });
|
||||
return;
|
||||
}
|
||||
replayDedup.add(dedupKey);
|
||||
if (replayDedup.size > MAX_DEDUP_SIZE) {
|
||||
const targetSize = Math.floor(MAX_DEDUP_SIZE * 0.8);
|
||||
for (const key of replayDedup) {
|
||||
if (replayDedup.size <= targetSize) break;
|
||||
replayDedup.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// auth_error — non-recoverable
|
||||
if (msg.type === "auth_error") {
|
||||
log.error("Authentication failed", { message: msg.payload.message });
|
||||
@@ -354,8 +327,6 @@ export function createWsClient() {
|
||||
if (msg.payload.replay_source === "none") {
|
||||
lastSeq = 0;
|
||||
}
|
||||
// Clear dedup cache — replay is complete
|
||||
replayDedup = null;
|
||||
setState("connected");
|
||||
reconnectAttempt = 0;
|
||||
startHeartbeat();
|
||||
@@ -459,10 +430,6 @@ export function createWsClient() {
|
||||
isReconnect: reconnectAttempt > 0,
|
||||
lastSeq,
|
||||
});
|
||||
// Enable dedup during reconnection replay
|
||||
if (reconnectAttempt > 0 && lastSeq > 0) {
|
||||
replayDedup = new Set();
|
||||
}
|
||||
setState("authenticating");
|
||||
if (config === null) return;
|
||||
// active_channel_id only matters on a resume (last_seq > 0); on a
|
||||
@@ -783,11 +750,6 @@ export function createWsClient() {
|
||||
return state;
|
||||
},
|
||||
|
||||
/** True while processing reconnection replay messages (dedup active). */
|
||||
isReplaying(): boolean {
|
||||
return replayDedup !== null;
|
||||
},
|
||||
|
||||
/** @internal for testing */
|
||||
_getWs(): WebSocket | null {
|
||||
return null;
|
||||
|
||||
@@ -98,10 +98,6 @@ export function createMockWsClient() {
|
||||
return state;
|
||||
},
|
||||
|
||||
isReplaying(): boolean {
|
||||
return false;
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test-only helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -85,10 +85,6 @@ function createMockWsClient(): MockWsClient {
|
||||
return currentState;
|
||||
},
|
||||
|
||||
isReplaying() {
|
||||
return false;
|
||||
},
|
||||
|
||||
_getWs() {
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -128,7 +128,6 @@ function createMockWs() {
|
||||
onCertMismatch: vi.fn(() => () => {}),
|
||||
acceptCertFingerprint: vi.fn(async () => {}),
|
||||
getState: vi.fn(() => "disconnected" as const),
|
||||
isReplaying: vi.fn(() => false),
|
||||
_getWs: vi.fn(() => null),
|
||||
};
|
||||
|
||||
@@ -378,11 +377,11 @@ describe("WS Dispatcher", () => {
|
||||
});
|
||||
|
||||
describe("chat_message notifications during a reconnect replay burst", () => {
|
||||
// ws.ts clears isReplaying() as soon as auth_ok is processed — before the
|
||||
// replay burst of chat_message frames the server sends right after it
|
||||
// even arrives — so it cannot gate notifications the way it gates the
|
||||
// unread counter above. A second auth_ok in this dispatcher's lifetime is
|
||||
// always a reconnect handshake; its timestamp is the gate instead.
|
||||
// The server writes auth_ok before the replay burst, so by the time
|
||||
// replayed chat_message frames arrive the client is already "connected"
|
||||
// — no connection-state flag can distinguish them. A second auth_ok in
|
||||
// this dispatcher's lifetime is always a reconnect handshake; its
|
||||
// timestamp is the gate instead.
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockNotifyIncomingMessage).mockClear();
|
||||
});
|
||||
@@ -3052,45 +3051,6 @@ describe("WS Dispatcher", () => {
|
||||
expect(channelsStore.getState().channels.get(5)?.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not increment unread during replay", () => {
|
||||
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(5, {
|
||||
id: 5,
|
||||
name: "other-ch",
|
||||
type: "text" as const,
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
canSend: true,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
return { ...prev, channels: ch, activeChannelId: 1 };
|
||||
});
|
||||
|
||||
mock.dispatch("chat_message", {
|
||||
id: 300,
|
||||
channel_id: 5,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "replayed message",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
|
||||
expect(channelsStore.getState().channels.get(5)?.unreadCount).toBe(0);
|
||||
|
||||
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("chat_message DM store updates", () => {
|
||||
const dmChannel = {
|
||||
channelId: 50,
|
||||
@@ -3178,33 +3138,6 @@ describe("WS Dispatcher", () => {
|
||||
expect(dm?.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it("updates DM preview (no unread) during replay", () => {
|
||||
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: { id: 5, username: "me", avatar: null, role: "member" },
|
||||
}));
|
||||
|
||||
mock.dispatch("chat_message", {
|
||||
id: 503,
|
||||
channel_id: 50,
|
||||
user: { id: 10, username: "bob", avatar: "" },
|
||||
content: "replayed DM",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
|
||||
const dms = dmStore.getState().channels;
|
||||
const dm = dms.find((c) => c.channelId === 50);
|
||||
expect(dm?.lastMessage).toBe("replayed DM");
|
||||
expect(dm?.unreadCount).toBe(0);
|
||||
|
||||
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("increments the DM mention badge for an incoming @mention", () => {
|
||||
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
|
||||
authStore.setState((prev) => ({
|
||||
|
||||
@@ -228,7 +228,6 @@ function fakeWs(): FakeWsClient {
|
||||
startCertListener: vi.fn(async () => {}),
|
||||
acceptCertFingerprint: vi.fn(async () => {}),
|
||||
getState: vi.fn(() => "connected" as ConnectionState),
|
||||
isReplaying: vi.fn(() => false),
|
||||
_getWs: vi.fn(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -730,30 +730,37 @@ describe("MessageList", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not report success when renderWindow's own >30-in-2s breaker drops the rebuild", () => {
|
||||
const many = Array.from({ length: 100 }, (_, i) => makeMessage({ id: i + 1 }));
|
||||
setMessages(1, many);
|
||||
msgList.mount(container); // mount's renderAll -> renderWindow: rebuild count = 1
|
||||
// 30 synchronous full renderWindow rebuilds of a 100-row list can exceed
|
||||
// vitest's default 5s on a loaded CI runner (audit-2026-08-19 F-5), so
|
||||
// this test carries its own timeout.
|
||||
it(
|
||||
"does not report success when renderWindow's own >30-in-2s breaker drops the rebuild",
|
||||
{ timeout: 20_000 },
|
||||
() => {
|
||||
const many = Array.from({ length: 100 }, (_, i) => makeMessage({ id: i + 1 }));
|
||||
setMessages(1, many);
|
||||
msgList.mount(container); // mount's renderAll -> renderWindow: rebuild count = 1
|
||||
|
||||
// Every scrollToMessage call forces renderedStart = -1 and calls
|
||||
// renderWindow() directly, bypassing renderAll's own (lower) rapid-fire
|
||||
// limit. 29 more calls bring the shared renderWindow rebuild counter to
|
||||
// 30 (still under the >30 breaker), each one landing normally.
|
||||
for (let i = 0; i < 29; i++) {
|
||||
expect(msgList.scrollToMessage(i + 1)).toBe(true);
|
||||
}
|
||||
// Every scrollToMessage call forces renderedStart = -1 and calls
|
||||
// renderWindow() directly, bypassing renderAll's own (lower) rapid-fire
|
||||
// limit. 29 more calls bring the shared renderWindow rebuild counter to
|
||||
// 30 (still under the >30 breaker), each one landing normally.
|
||||
for (let i = 0; i < 29; i++) {
|
||||
expect(msgList.scrollToMessage(i + 1)).toBe(true);
|
||||
}
|
||||
|
||||
// The 30th call pushes the counter to 31 and trips the breaker inside
|
||||
// renderWindow: it returns before reassigning renderedStart/renderedEnd
|
||||
// or touching the DOM, so the target (far outside the last rendered
|
||||
// window) never actually renders.
|
||||
const result = msgList.scrollToMessage(90);
|
||||
// The 30th call pushes the counter to 31 and trips the breaker inside
|
||||
// renderWindow: it returns before reassigning renderedStart/renderedEnd
|
||||
// or touching the DOM, so the target (far outside the last rendered
|
||||
// window) never actually renders.
|
||||
const result = msgList.scrollToMessage(90);
|
||||
|
||||
// The rebuild did not happen — the row is not in the DOM — so this must
|
||||
// be reported as a failed jump (matching the "false if the message is
|
||||
// not in the loaded window" contract), not a false "true".
|
||||
expect(container.querySelector('[data-testid="message-90"]')).toBeNull();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
// The rebuild did not happen — the row is not in the DOM — so this must
|
||||
// be reported as a failed jump (matching the "false if the message is
|
||||
// not in the loaded window" contract), not a false "true".
|
||||
expect(container.querySelector('[data-testid="message-90"]')).toBeNull();
|
||||
expect(result).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ function createMockWs(): WsClient {
|
||||
onCertMismatch: vi.fn().mockReturnValue(() => {}),
|
||||
acceptCertFingerprint: vi.fn(),
|
||||
getState: vi.fn(() => "connected"),
|
||||
isReplaying: vi.fn(() => false),
|
||||
_getWs: vi.fn(() => null),
|
||||
} as unknown as WsClient;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ function createMockWs(state: "connected" | "disconnected" = "connected"): WsClie
|
||||
onCertMismatch: vi.fn().mockReturnValue(() => {}),
|
||||
acceptCertFingerprint: vi.fn(),
|
||||
getState: vi.fn(() => currentState),
|
||||
isReplaying: vi.fn(() => false),
|
||||
_getWs: vi.fn(() => null),
|
||||
_setState(s: "connected" | "disconnected") {
|
||||
currentState = s;
|
||||
|
||||
@@ -28,7 +28,7 @@ function getAuthPayload(): Record<string, unknown> {
|
||||
return parsed.payload;
|
||||
}
|
||||
|
||||
describe("auth frame: active_channel_id + reconnect-replay dedup arming", () => {
|
||||
describe("auth frame: active_channel_id", () => {
|
||||
let client: ReturnType<typeof createWsClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -50,7 +50,7 @@ describe("auth frame: active_channel_id + reconnect-replay dedup arming", () =>
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fresh connect (reconnectAttempt=0, lastSeq=0): no active_channel_id key even with a provider registered, and dedup is not armed", async () => {
|
||||
it("fresh connect (reconnectAttempt=0, lastSeq=0): no active_channel_id key even with a provider registered", async () => {
|
||||
setActiveChannelProvider(() => 99);
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
@@ -59,7 +59,6 @@ describe("auth frame: active_channel_id + reconnect-replay dedup arming", () =>
|
||||
const payload = getAuthPayload();
|
||||
expect(payload.last_seq).toBe(0);
|
||||
expect(Object.prototype.hasOwnProperty.call(payload, "active_channel_id")).toBe(false);
|
||||
expect(client.isReplaying()).toBe(false);
|
||||
});
|
||||
|
||||
it("reconnect with lastSeq > 0 and a registered provider: active_channel_id carries the provider's id", async () => {
|
||||
@@ -151,7 +150,7 @@ describe("auth frame: active_channel_id + reconnect-replay dedup arming", () =>
|
||||
expect(Object.prototype.hasOwnProperty.call(payload, "active_channel_id")).toBe(false);
|
||||
});
|
||||
|
||||
it("lastSeq > 0 but reconnectAttempt === 0: the provider is still consulted (gated on lastSeq, not reconnect count) and dedup stays unarmed", async () => {
|
||||
it("lastSeq > 0 but reconnectAttempt === 0: the provider is still consulted (gated on lastSeq, not reconnect count)", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
// First "open": reconnectAttempt=0, lastSeq=0 — irrelevant, just gets us started.
|
||||
@@ -177,11 +176,9 @@ describe("auth frame: active_channel_id + reconnect-replay dedup arming", () =>
|
||||
const payload = getAuthPayload();
|
||||
expect(payload.last_seq).toBe(9);
|
||||
expect(payload.active_channel_id).toBe(5);
|
||||
// Dedup requires reconnectAttempt > 0 too — must still be unarmed.
|
||||
expect(client.isReplaying()).toBe(false);
|
||||
});
|
||||
|
||||
it("reconnectAttempt > 0 but lastSeq === 0: no active_channel_id key and dedup stays unarmed", async () => {
|
||||
it("reconnectAttempt > 0 but lastSeq === 0: no active_channel_id key", async () => {
|
||||
setActiveChannelProvider(() => 5);
|
||||
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
@@ -197,54 +194,5 @@ describe("auth frame: active_channel_id + reconnect-replay dedup arming", () =>
|
||||
const payload = getAuthPayload();
|
||||
expect(payload.last_seq).toBe(0);
|
||||
expect(Object.prototype.hasOwnProperty.call(payload, "active_channel_id")).toBe(false);
|
||||
expect(client.isReplaying()).toBe(false);
|
||||
});
|
||||
|
||||
it("dedup is armed only when BOTH reconnectAttempt > 0 AND lastSeq > 0: a genuine reconnect replay dedups a repeated message (fresh-connect non-arming is covered above)", 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-state", "closed");
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
// Both conditions true now: reconnectAttempt=1, lastSeq=1.
|
||||
expect(client.isReplaying()).toBe(true);
|
||||
|
||||
const replayed: unknown[] = [];
|
||||
client.on("chat_message", (p) => replayed.push(p));
|
||||
|
||||
const dupMsg = JSON.stringify({
|
||||
type: "chat_message",
|
||||
seq: 5,
|
||||
id: "dup-msg",
|
||||
payload: {
|
||||
id: 1,
|
||||
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", dupMsg);
|
||||
emitTauriEvent("ws-message", dupMsg);
|
||||
|
||||
expect(replayed).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1012,107 +1012,6 @@ describe("cleanupEventListeners edge cases", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Listener registry mechanics (no Tauri connection needed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("listener registry mechanics (on/off/dispatch)", () => {
|
||||
let client: ReturnType<typeof createWsClient>;
|
||||
|
||||
|
||||
@@ -244,10 +244,6 @@ describe("message handling edge cases", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -389,7 +389,7 @@ describe("lastSeq tracking", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnection dedup", () => {
|
||||
describe("reconnection replay handling", () => {
|
||||
let client: ReturnType<typeof createWsClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -406,12 +406,11 @@ describe("reconnection dedup", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("deduplicates messages during reconnection replay", async () => {
|
||||
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");
|
||||
|
||||
// Auth and get some messages to advance lastSeq
|
||||
emitTauriEvent(
|
||||
"ws-message",
|
||||
JSON.stringify({
|
||||
@@ -424,7 +423,6 @@ describe("reconnection dedup", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
emitTauriEvent(
|
||||
"ws-message",
|
||||
JSON.stringify({
|
||||
@@ -435,7 +433,7 @@ describe("reconnection dedup", () => {
|
||||
id: 1,
|
||||
channel_id: 1,
|
||||
user: { id: 1, username: "a", avatar: null },
|
||||
content: "original",
|
||||
content: "before disconnect",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
@@ -443,186 +441,51 @@ describe("reconnection dedup", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// 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);
|
||||
// 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));
|
||||
|
||||
// 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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
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);
|
||||
|
||||
// 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);
|
||||
expect(messages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -854,115 +717,6 @@ describe("scheduleReconnect guard clauses", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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>;
|
||||
|
||||
@@ -980,7 +734,7 @@ describe("auth_error during reconnection replay", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auth_error is not deduped during replay and stops reconnect", async () => {
|
||||
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");
|
||||
@@ -1002,12 +756,10 @@ describe("auth_error during reconnection replay", () => {
|
||||
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({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
// Typechecks the Playwright layer (47 spec files + fixtures + the three
|
||||
// Typechecks the Playwright layer (every tests/e2e spec + fixtures + the
|
||||
// playwright configs), which the main tsconfig deliberately excludes from
|
||||
// the app graph. "exclude": [] is required to clear the inherited
|
||||
// "tests/e2e" exclusion — same trick tsconfig.build.json uses.
|
||||
|
||||
Reference in New Issue
Block a user