fix(offline): address PR #120 review (cross-user leak, Yjs title dup, i18n, docs, guards)
Security: - Clear the offline IndexedDB cache on sign-in (not only logout) so a previous user's persisted query cache and Yjs page bodies cannot leak to the next user on a shared device when the prior session ended without an explicit logout. Regressions: - Remove the double Yjs title write from the AI title-generation path: the title editor is bound to the Yjs `title` fragment and the server REST update reseeds it, so the local setContent raced that reseed and doubled/garbled the title. Conventions / i18n / docs: - Remove the unused showAiMenuAtom. - Register the 3 offline-fallback strings in en-US and ru-RU. - Fix the 5 broken links to the nonexistent docs/offline-sync-plan.md. Stability / simplification: - warmInfiniteAll now reports truncation (returns false) when it hits maxPages with a cursor still pending instead of silently succeeding. - space-tree make-offline catch logs the raw error and surfaces the real cause. - Move the Offline/Mobile/CORS CHANGELOG entries from the released 0.93.0 section into [Unreleased] (CORS is a documented breaking change). - Drop the pass-through sync-flag forwarders in use-page-collab-providers; set the atoms directly. - Collapse the three isSwaggerEnabled true-cases into it.each. Tests / architecture: - Extract collabTokenNeedsRefresh (pure) and cover all four token states. - Extract shouldPropagateTitleChange and cover the collab-origin skip; add a TitleEditor render test for the static-h1 vs collaborative-editor switch. - Add a use-auth test asserting the sign-in cache purge runs before login. - Add an OFFLINE_PERSIST_ROOTS guard test asserting every persisted root maps to an exported query-key factory; route make-offline's currentUser warm through a new userKeys factory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
apps/client/src/features/auth/hooks/use-auth.test.ts
Normal file
91
apps/client/src/features/auth/hooks/use-auth.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// react-i18next: identity t() so the hook renders without an i18n provider.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// react-router-dom: only useNavigate is used by the hook.
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
// The auth service is the network boundary; stub login per test.
|
||||
const loginMock = vi.fn();
|
||||
vi.mock("@/features/auth/services/auth-service", () => ({
|
||||
login: (...args: unknown[]) => loginMock(...args),
|
||||
logout: vi.fn(),
|
||||
forgotPassword: vi.fn(),
|
||||
passwordReset: vi.fn(),
|
||||
setupWorkspace: vi.fn(),
|
||||
verifyUserToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
|
||||
acceptInvitation: vi.fn(),
|
||||
}));
|
||||
|
||||
// The offline cache purge is the unit under test — assert it is invoked.
|
||||
const clearOfflineCacheMock = vi.fn();
|
||||
vi.mock("@/features/offline/clear-offline-cache", () => ({
|
||||
clearOfflineCache: () => clearOfflineCacheMock(),
|
||||
}));
|
||||
|
||||
// app-route helpers are pure config; provide deterministic values.
|
||||
vi.mock("@/lib/app-route.ts", () => ({
|
||||
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
|
||||
getPostLoginRedirect: () => "/home",
|
||||
}));
|
||||
|
||||
// Mantine notifications: avoid touching the DOM-bound notification system.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
import useAuth from "./use-auth";
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
loginMock.mockReset();
|
||||
loginMock.mockResolvedValue(undefined);
|
||||
clearOfflineCacheMock.mockReset();
|
||||
clearOfflineCacheMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("useAuth.handleSignIn", () => {
|
||||
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
|
||||
const order: string[] = [];
|
||||
clearOfflineCacheMock.mockImplementation(async () => {
|
||||
order.push("clear");
|
||||
});
|
||||
loginMock.mockImplementation(async () => {
|
||||
order.push("login");
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||
});
|
||||
|
||||
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||
// The purge must run before the new session's login resolves.
|
||||
expect(order).toEqual(["clear", "login"]);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
|
||||
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||
});
|
||||
|
||||
// Login still proceeds despite the cleanup failure.
|
||||
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user