Files
OwnCord/Client/tauri-client/tests/unit/auth.store.test.ts
T
J3vbandClaude Fable 5 d880b64d64 test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* 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>
2026-08-20 19:29:31 +02:00

520 lines
18 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import {
authStore,
setAuth,
clearAuth,
getToken,
getCurrentUser,
updateUser,
} from "../../src/stores/auth.store";
import {
voiceStore,
resetVoiceStore,
joinVoiceChannel,
setVoiceStatus,
} from "../../src/stores/voice.store";
import { leaveVoice } from "@lib/livekitSession";
import { setMessages, isChannelLoaded, getChannelMessages } from "../../src/stores/messages.store";
import { channelsStore, setChannels } from "../../src/stores/channels.store";
import type { ReadyChannel } from "../../src/lib/types";
import { acknowledgeNsfw, isNsfwAcknowledged } from "../../src/lib/nsfw-gate";
import { addLogListener, type LogEntry } from "@lib/logger";
import type { UserWithRole, MessageResponse, MessageUser } from "../../src/lib/types";
// Mock the lazily-imported voice SDK module so we can assert clearAuth() only
// pulls it in (loading the ~1.3 MB LiveKit chunk) when a voice session exists.
vi.mock("@lib/livekitSession", () => ({
leaveVoice: vi.fn(),
}));
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0));
const TEST_USER: UserWithRole = {
id: 42,
username: "testuser",
avatar: "avatar.png",
role: "member",
};
const TEST_TOKEN = "session-token-abc123";
const TEST_SERVER_NAME = "My OwnCord Server";
const TEST_MOTD = "Welcome to OwnCord!";
function resetStore(): void {
clearAuth();
}
describe("auth store", () => {
beforeEach(() => {
resetStore();
});
// 1. Initial state is unauthenticated
describe("initial state", () => {
it("has null token", () => {
expect(authStore.getState().token).toBeNull();
});
it("has null user", () => {
expect(authStore.getState().user).toBeNull();
});
it("has null serverName", () => {
expect(authStore.getState().serverName).toBeNull();
});
it("has null motd", () => {
expect(authStore.getState().motd).toBeNull();
});
it("is not authenticated", () => {
expect(authStore.getState().isAuthenticated).toBe(false);
});
});
// 2. setAuth populates all fields correctly
describe("setAuth", () => {
it("sets token", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(authStore.getState().token).toBe(TEST_TOKEN);
});
it("sets user", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(authStore.getState().user).toEqual(TEST_USER);
});
it("sets serverName", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(authStore.getState().serverName).toBe(TEST_SERVER_NAME);
});
it("sets motd", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(authStore.getState().motd).toBe(TEST_MOTD);
});
it("sets isAuthenticated to true", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(authStore.getState().isAuthenticated).toBe(true);
});
it("returns a new state object on each call", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const first = authStore.getState();
setAuth("other-token", TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const second = authStore.getState();
expect(first).not.toBe(second);
});
});
// 3. clearAuth resets to initial state
describe("clearAuth", () => {
it("resets all fields after being authenticated", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
clearAuth();
const state = authStore.getState();
expect(state.token).toBeNull();
expect(state.user).toBeNull();
expect(state.serverName).toBeNull();
expect(state.motd).toBeNull();
expect(state.isAuthenticated).toBe(false);
});
it("produces a new state object", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const before = authStore.getState();
clearAuth();
const after = authStore.getState();
expect(before).not.toBe(after);
});
// v076: acknowledgements are per-viewer consent, not per-device. Host
// scoping cannot cover a second account on the SAME server, so the age
// gate must be re-armed on logout or the next user silently inherits it.
it("clears NSFW acknowledgements so the next account re-sees the age gate", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
acknowledgeNsfw(12);
expect(isNsfwAcknowledged(12)).toBe(true);
clearAuth();
expect(isNsfwAcknowledged(12)).toBe(false);
});
it("records 'user' as the default logout reason", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
clearAuth();
expect(authStore.getState().logoutReason).toBe("user");
});
it("records an explicit logout reason and resets it on the next setAuth", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
clearAuth("server_shutdown");
expect(authStore.getState().logoutReason).toBe("server_shutdown");
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(authStore.getState().logoutReason).toBeUndefined();
});
});
// 4. getToken returns current token
describe("getToken", () => {
it("returns null when unauthenticated", () => {
expect(getToken()).toBeNull();
});
it("returns token after setAuth", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(getToken()).toBe(TEST_TOKEN);
});
it("returns null after clearAuth", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
clearAuth();
expect(getToken()).toBeNull();
});
});
// 5. updateUser patches user fields
describe("updateUser", () => {
it("updates username on authenticated user", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
updateUser({ username: "newname" });
expect(authStore.getState().user?.username).toBe("newname");
});
it("preserves other user fields when patching", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
updateUser({ username: "newname" });
const user = authStore.getState().user;
expect(user?.id).toBe(42);
expect(user?.avatar).toBe("avatar.png");
expect(user?.role).toBe("member");
});
it("is a no-op when user is null", () => {
updateUser({ username: "newname" });
expect(authStore.getState().user).toBeNull();
});
it("produces a new state object", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const before = authStore.getState();
updateUser({ username: "changed" });
expect(authStore.getState()).not.toBe(before);
});
it("produces a new user object (immutable)", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const userBefore = authStore.getState().user;
updateUser({ avatar: "new-avatar.png" });
const userAfter = authStore.getState().user;
expect(userBefore).not.toBe(userAfter);
expect(userAfter?.avatar).toBe("new-avatar.png");
});
it("sets totp_enabled to true", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
updateUser({ totp_enabled: true });
expect(authStore.getState().user?.totp_enabled).toBe(true);
});
it("sets totp_enabled to false", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
updateUser({ totp_enabled: true });
updateUser({ totp_enabled: false });
expect(authStore.getState().user?.totp_enabled).toBe(false);
});
it("initial user has no totp_enabled (undefined)", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(authStore.getState().user?.totp_enabled).toBeUndefined();
});
});
// 6. getCurrentUser returns current user
describe("getCurrentUser", () => {
it("returns null when unauthenticated", () => {
expect(getCurrentUser()).toBeNull();
});
it("returns user after setAuth", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(getCurrentUser()).toEqual(TEST_USER);
});
it("returns null after clearAuth", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
clearAuth();
expect(getCurrentUser()).toBeNull();
});
});
// clearAuth voice-session cleanup (regression: don't force-load the LiveKit
// chunk on every logout/401 for a text-only user).
describe("clearAuth voice cleanup", () => {
beforeEach(() => {
resetVoiceStore();
vi.mocked(leaveVoice).mockClear();
});
it("does NOT load livekitSession when there is no active voice session", async () => {
// Voice store is idle (currentChannelId null, voiceStatus "idle").
clearAuth();
await flushMicrotasks();
expect(leaveVoice).not.toHaveBeenCalled();
});
it("leaves voice when a voice session is active", async () => {
joinVoiceChannel(7); // currentChannelId=7, voiceStatus="joining"
setVoiceStatus("connected");
clearAuth();
await flushMicrotasks();
expect(leaveVoice).toHaveBeenCalledWith(false);
});
// Boundary: a channel id can outlive the status settling back to idle
// (e.g. a leave that updated voiceStatus but hasn't cleared
// currentChannelId yet). clearAuth's guard is an AND of both conditions,
// not just "was a channel ever joined" — this pins that a set channel id
// alone must NOT trigger another leaveVoice call once already idle.
it("does NOT load livekitSession when the channel id is set but status is already idle", async () => {
joinVoiceChannel(7);
setVoiceStatus("idle");
clearAuth();
await flushMicrotasks();
expect(leaveVoice).not.toHaveBeenCalled();
});
// Boundary: the inverse — a non-idle status alone (no channel id) must
// also NOT trigger leaveVoice. Together with the case above, this pins
// that clearAuth requires BOTH currentChannelId set AND status !== idle,
// not either one alone.
it("does NOT load livekitSession when status is non-idle but no channel id is set", async () => {
setVoiceStatus("reconnecting");
clearAuth();
await flushMicrotasks();
expect(leaveVoice).not.toHaveBeenCalled();
});
it("logs a warning tagged with this module's component name when leaveVoice rejects", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const entries: LogEntry[] = [];
const unsub = addLogListener((e) => entries.push(e));
vi.mocked(leaveVoice).mockRejectedValueOnce(new Error("boom"));
joinVoiceChannel(7);
setVoiceStatus("connected");
clearAuth();
await flushMicrotasks();
unsub();
const warnEntry = entries.find((e) => e.level === "warn");
expect(warnEntry?.component).toBe("auth.store");
expect(warnEntry?.message).toBe("Failed to leave voice session during clearAuth");
});
});
// clearAuth's logoutWasInVoice snapshot — main.ts's isAuthenticated
// subscriber gates its voice_leave send on this instead of re-reading
// voiceStore, which clearAuth has already reset by the time any subscriber
// observes the transition (store notifications are microtask-deferred).
describe("clearAuth logoutWasInVoice snapshot", () => {
beforeEach(() => {
resetVoiceStore();
});
it("is false when not in a voice channel at logout", () => {
clearAuth();
expect(authStore.getState().logoutWasInVoice).toBe(false);
});
it("snapshots true when in a voice channel, surviving clearAuth's own voiceStore reset", () => {
joinVoiceChannel(7);
clearAuth();
expect(authStore.getState().logoutWasInVoice).toBe(true);
// The snapshot must reflect voice state as it was BEFORE this same
// call reset it — not the (already-idle) state read afterward.
expect(voiceStore.getState().currentChannelId).toBeNull();
});
});
// 6. Subscribe receives updates on setAuth/clearAuth
describe("subscribe", () => {
it("notifies on setAuth", () => {
const listener = vi.fn();
const unsub = authStore.subscribe(listener);
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
authStore.flush();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
token: TEST_TOKEN,
user: TEST_USER,
serverName: TEST_SERVER_NAME,
motd: TEST_MOTD,
isAuthenticated: true,
}),
);
unsub();
});
it("notifies on clearAuth", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const listener = vi.fn();
const unsub = authStore.subscribe(listener);
clearAuth();
authStore.flush();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}),
);
unsub();
});
it("does not notify after unsubscribe", () => {
const listener = vi.fn();
const unsub = authStore.subscribe(listener);
unsub();
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
expect(listener).not.toHaveBeenCalled();
});
it("notifies multiple subscribers independently", () => {
const listenerA = vi.fn();
const listenerB = vi.fn();
const unsubA = authStore.subscribe(listenerA);
const unsubB = authStore.subscribe(listenerB);
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
authStore.flush();
expect(listenerA).toHaveBeenCalledTimes(1);
expect(listenerB).toHaveBeenCalledTimes(1);
unsubA();
unsubB();
});
});
// Regression: clearAuth() must also drop messagesStore, or a channel id
// that also exists on the next-signed-into server (channel ids are only
// unique per-server) renders the previous session's cached messages and
// never refetches, because MessageController.loadMessages short-circuits
// on isChannelLoaded.
describe("clearAuth messages cleanup", () => {
const AUTHOR: MessageUser = { id: 1, username: "alice", avatar: "alice.png" };
function makeMessageResponse(overrides?: Partial<MessageResponse>): MessageResponse {
return {
id: 1,
channel_id: 1,
user: AUTHOR,
content: "pre-logout message",
reply_to: null,
attachments: [],
reactions: [],
pinned: false,
edited_at: null,
deleted: false,
timestamp: "2026-03-15T10:00:00Z",
...overrides,
};
}
it("clears cached messages and the loaded flag on logout", () => {
setMessages(1, [makeMessageResponse()], false);
expect(isChannelLoaded(1)).toBe(true);
expect(getChannelMessages(1)).toHaveLength(1);
clearAuth();
expect(isChannelLoaded(1)).toBe(false);
expect(getChannelMessages(1)).toHaveLength(0);
});
it("does not leak the previous session's message content into the next", () => {
setMessages(1, [makeMessageResponse({ content: "server A secret" })], false);
clearAuth();
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
// Same numeric channel id, different server: must come back empty and
// unloaded so the caller refetches instead of rendering stale content.
expect(isChannelLoaded(1)).toBe(false);
expect(getChannelMessages(1)).toHaveLength(0);
});
});
// Regression: clearAuth() must also drop channelsStore, or setChannels'
// DM-row carry (a DM channel row is deliberately preserved across a normal
// `ready` rebuild, since ready never restates DM rows) re-inserts the
// PREVIOUS server's DM channel ids into the NEXT server's channel map on
// the next login — a stale phantom channel signed into an unrelated server.
describe("clearAuth channels cleanup", () => {
const readyChannels: ReadyChannel[] = [
{ id: 1, name: "general", type: "text", category: "Text", position: 0, unread_count: 3 },
];
it("clears channels, activeChannelId, and roles on logout", () => {
setChannels(readyChannels);
expect(channelsStore.getState().channels.size).toBe(1);
clearAuth();
expect(channelsStore.getState().channels.size).toBe(0);
expect(channelsStore.getState().activeChannelId).toBeNull();
});
it("does not carry the previous server's DM channel row into the next session", () => {
setChannels(readyChannels);
// Synthesize a DM row the way addDmToChannelsStore does — setChannels'
// carry loop (channels.store.ts) re-inserts any "dm"-typed row across
// every future setChannels call unless the store is reset first.
channelsStore.setState((prev) => {
const next = new Map(prev.channels);
next.set(999, {
id: 999,
name: "alice",
type: "dm",
category: null,
topic: "",
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels: next };
});
expect(channelsStore.getState().channels.has(999)).toBe(true);
clearAuth();
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
setChannels([]); // the next server's `ready` — no DMs of its own yet
expect(channelsStore.getState().channels.has(999)).toBe(false);
});
});
});