mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 18:52:08 +01:00
feat: implement comprehensive CSRF protection
This commit is contained in:
324
tests/unit/csrf-hooks.test.tsx
Normal file
324
tests/unit/csrf-hooks.test.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
/**
|
||||
* CSRF Hooks Tests
|
||||
*
|
||||
* Tests for React hooks that manage CSRF tokens on the client side.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useCSRF, useCSRFFetch, useCSRFForm } from "../../lib/hooks/useCSRF";
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Mock document.cookie
|
||||
Object.defineProperty(document, "cookie", {
|
||||
writable: true,
|
||||
value: "",
|
||||
});
|
||||
|
||||
describe("CSRF Hooks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.cookie = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("useCSRF", () => {
|
||||
it("should initialize with loading state", () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, token: "test-token" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRF());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should fetch token on mount when no cookie exists", async () => {
|
||||
const mockToken = "test-csrf-token";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, token: mockToken }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRF());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/csrf-token", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
expect(result.current.token).toBe(mockToken);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should use existing token from cookies", async () => {
|
||||
const existingToken = "existing-csrf-token";
|
||||
document.cookie = `csrf-token=${existingToken}`;
|
||||
|
||||
// Mock fetch to ensure it's not called when token exists
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, token: "should-not-be-used" }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRF());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.token).toBe(existingToken);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Should not fetch from server if cookie exists
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle fetch errors", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useCSRF());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeTruthy();
|
||||
expect(result.current.token).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle invalid response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: false }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRF());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeTruthy();
|
||||
expect(result.current.token).toBeNull();
|
||||
});
|
||||
|
||||
it("should refresh token manually", async () => {
|
||||
const newToken = "refreshed-csrf-token";
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, token: "initial-token" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, token: newToken }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRF());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
await result.current.refreshToken();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.token).toBe(newToken);
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCSRFFetch", () => {
|
||||
it("should add CSRF token to POST requests", async () => {
|
||||
const token = "test-token";
|
||||
document.cookie = `csrf-token=${token}`;
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRFFetch());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.token).toBe(token);
|
||||
});
|
||||
|
||||
await result.current.csrfFetch("/api/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: expect.objectContaining({
|
||||
"x-csrf-token": token,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add CSRF token to GET requests", async () => {
|
||||
const token = "test-token";
|
||||
document.cookie = `csrf-token=${token}`;
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRFFetch());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.token).toBe(token);
|
||||
});
|
||||
|
||||
await result.current.csrfFetch("/api/test", {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/test",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0][1];
|
||||
expect(callArgs.headers?.["x-csrf-token"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle missing token gracefully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRFFetch());
|
||||
|
||||
await result.current.csrfFetch("/api/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCSRFForm", () => {
|
||||
it("should add CSRF token to form data", async () => {
|
||||
const token = "test-token";
|
||||
document.cookie = `csrf-token=${token}`;
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRFForm());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.token).toBe(token);
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("data", "test");
|
||||
|
||||
await result.current.submitForm("/api/test", formData);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: expect.any(FormData),
|
||||
})
|
||||
);
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0][1];
|
||||
const submittedFormData = callArgs.body as FormData;
|
||||
expect(submittedFormData.get("csrf_token")).toBe(token);
|
||||
});
|
||||
|
||||
it("should add CSRF token to JSON data", async () => {
|
||||
const token = "test-token";
|
||||
document.cookie = `csrf-token=${token}`;
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRFForm());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.token).toBe(token);
|
||||
});
|
||||
|
||||
const data = { data: "test" };
|
||||
|
||||
await result.current.submitJSON("/api/test", data);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ ...data, csrfToken: token }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing token in form submission", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCSRFForm());
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("data", "test");
|
||||
|
||||
await result.current.submitForm("/api/test", formData);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: expect.any(FormData),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user