Files
stack/apps/web/src/hooks/useChatOverlay.test.ts
T
Jason WoltjeandClaude Opus 4.6 14b547d468 fix(SEC-WEB-30+31+36): Validate JSON.parse/localStorage deserialization
Add runtime type validation after all JSON.parse calls in the web app to
prevent runtime crashes from corrupted or tampered storage data. Creates a
shared safeJsonParse utility with type guard functions for each data shape
(Message[], ChatOverlayState, LayoutConfigRecord). All four affected
callsites now validate parsed data and fall back to safe defaults on
mismatch.

Files changed:
- apps/web/src/lib/utils/safe-json.ts (new utility)
- apps/web/src/lib/utils/safe-json.test.ts (25 tests)
- apps/web/src/hooks/useChat.ts (deserializeMessages)
- apps/web/src/hooks/useChat.test.ts (3 new corruption tests)
- apps/web/src/hooks/useChatOverlay.ts (loadState)
- apps/web/src/hooks/useChatOverlay.test.ts (3 new corruption tests)
- apps/web/src/components/chat/ConversationSidebar.tsx (ideaToConversation)
- apps/web/src/lib/hooks/useLayout.ts (layout loading)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-06 15:46:58 -06:00

309 lines
8.1 KiB
TypeScript

/**
* @file useChatOverlay.test.ts
* @description Tests for the useChatOverlay hook that manages chat overlay state
*/
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useChatOverlay } from "./useChatOverlay";
// Mock localStorage
const localStorageMock = ((): Storage => {
let store: Record<string, string> = {};
return {
getItem: (key: string): string | null => store[key] ?? null,
setItem: (key: string, value: string): void => {
store[key] = value;
},
removeItem: (key: string): void => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete store[key];
},
clear: (): void => {
store = {};
},
get length(): number {
return Object.keys(store).length;
},
key: (index: number): string | null => {
const keys = Object.keys(store);
return keys[index] ?? null;
},
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
describe("useChatOverlay", () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
describe("initial state", () => {
it("should initialize with closed and not minimized state", () => {
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
it("should restore state from localStorage if available", () => {
localStorageMock.setItem(
"chatOverlayState",
JSON.stringify({ isOpen: true, isMinimized: true })
);
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(true);
expect(result.current.isMinimized).toBe(true);
});
it("should handle invalid localStorage data gracefully", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
localStorageMock.setItem("chatOverlayState", "invalid json");
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
it("should fall back to defaults when localStorage has wrong shape", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
// Valid JSON but wrong shape
localStorageMock.setItem("chatOverlayState", JSON.stringify({ isOpen: "yes", wrong: true }));
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
it("should fall back to defaults when localStorage has null value parsed", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
localStorageMock.setItem("chatOverlayState", "null");
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
it("should fall back to defaults when localStorage has array instead of object", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
localStorageMock.setItem("chatOverlayState", JSON.stringify([true, false]));
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
});
describe("open", () => {
it("should open the chat overlay", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
expect(result.current.isMinimized).toBe(false);
});
it("should persist state to localStorage when opening", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
});
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
isOpen: boolean;
isMinimized: boolean;
};
expect(stored.isOpen).toBe(true);
expect(stored.isMinimized).toBe(false);
});
});
describe("close", () => {
it("should close the chat overlay", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
});
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
});
it("should persist state to localStorage when closing", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
});
act(() => {
result.current.close();
});
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
isOpen: boolean;
isMinimized: boolean;
};
expect(stored.isOpen).toBe(false);
});
});
describe("minimize", () => {
it("should minimize the chat overlay", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
});
act(() => {
result.current.minimize();
});
expect(result.current.isOpen).toBe(true);
expect(result.current.isMinimized).toBe(true);
});
it("should persist minimized state to localStorage", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
});
act(() => {
result.current.minimize();
});
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
isOpen: boolean;
isMinimized: boolean;
};
expect(stored.isMinimized).toBe(true);
});
});
describe("expand", () => {
it("should expand the minimized chat overlay", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
result.current.minimize();
});
act(() => {
result.current.expand();
});
expect(result.current.isMinimized).toBe(false);
});
it("should persist expanded state to localStorage", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
result.current.minimize();
});
act(() => {
result.current.expand();
});
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
isOpen: boolean;
isMinimized: boolean;
};
expect(stored.isMinimized).toBe(false);
});
});
describe("toggle", () => {
it("should toggle the chat overlay open state", () => {
const { result } = renderHook(() => useChatOverlay());
// Initially closed
expect(result.current.isOpen).toBe(false);
// Toggle to open
act(() => {
result.current.toggle();
});
expect(result.current.isOpen).toBe(true);
// Toggle to close
act(() => {
result.current.toggle();
});
expect(result.current.isOpen).toBe(false);
});
it("should not change minimized state when toggling", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
result.current.minimize();
});
expect(result.current.isMinimized).toBe(true);
act(() => {
result.current.toggle();
});
// Should close but keep minimized state for next open
expect(result.current.isOpen).toBe(false);
});
});
describe("toggleMinimize", () => {
it("should toggle the minimize state", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
result.current.open();
});
// Initially not minimized
expect(result.current.isMinimized).toBe(false);
// Toggle to minimized
act(() => {
result.current.toggleMinimize();
});
expect(result.current.isMinimized).toBe(true);
// Toggle back to expanded
act(() => {
result.current.toggleMinimize();
});
expect(result.current.isMinimized).toBe(false);
});
});
});