mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 10:52:08 +01:00
feat: implement comprehensive CSRF protection
This commit is contained in:
253
tests/integration/csrf-protection.test.ts
Normal file
253
tests/integration/csrf-protection.test.ts
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* CSRF Protection Integration Tests
|
||||
*
|
||||
* End-to-end tests for CSRF protection in API endpoints and middleware.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { csrfProtectionMiddleware, csrfTokenMiddleware } from "../../middleware/csrfProtection";
|
||||
import { generateCSRFToken } from "../../lib/csrf";
|
||||
|
||||
describe("CSRF Protection Integration", () => {
|
||||
describe("CSRF Token Middleware", () => {
|
||||
it("should serve CSRF token on GET /api/csrf-token", async () => {
|
||||
const { req } = createMocks({
|
||||
method: "GET",
|
||||
url: "/api/csrf-token",
|
||||
});
|
||||
|
||||
const request = {
|
||||
method: "GET",
|
||||
nextUrl: { pathname: "/api/csrf-token" },
|
||||
} as NextRequest;
|
||||
|
||||
const response = csrfTokenMiddleware(request);
|
||||
expect(response).not.toBeNull();
|
||||
|
||||
if (response) {
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.token).toBeDefined();
|
||||
expect(typeof body.token).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null for non-csrf-token paths", async () => {
|
||||
const request = {
|
||||
method: "GET",
|
||||
nextUrl: { pathname: "/api/other" },
|
||||
} as NextRequest;
|
||||
|
||||
const response = csrfTokenMiddleware(request);
|
||||
expect(response).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSRF Protection Middleware", () => {
|
||||
it("should allow GET requests without CSRF token", async () => {
|
||||
const request = {
|
||||
method: "GET",
|
||||
nextUrl: { pathname: "/api/dashboard" },
|
||||
} as NextRequest;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("should allow HEAD requests without CSRF token", async () => {
|
||||
const request = {
|
||||
method: "HEAD",
|
||||
nextUrl: { pathname: "/api/dashboard" },
|
||||
} as NextRequest;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("should allow OPTIONS requests without CSRF token", async () => {
|
||||
const request = {
|
||||
method: "OPTIONS",
|
||||
nextUrl: { pathname: "/api/dashboard" },
|
||||
} as NextRequest;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("should block POST request to protected endpoint without CSRF token", async () => {
|
||||
const request = {
|
||||
method: "POST",
|
||||
nextUrl: { pathname: "/api/dashboard/sessions" },
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
cookies: {
|
||||
get: () => undefined,
|
||||
},
|
||||
clone: () => ({
|
||||
json: async () => ({}),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain("CSRF token");
|
||||
});
|
||||
|
||||
it("should allow POST request to unprotected endpoint without CSRF token", async () => {
|
||||
const request = {
|
||||
method: "POST",
|
||||
nextUrl: { pathname: "/api/unprotected" },
|
||||
} as NextRequest;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("should allow POST request with valid CSRF token", async () => {
|
||||
const token = generateCSRFToken();
|
||||
|
||||
const request = {
|
||||
method: "POST",
|
||||
nextUrl: { pathname: "/api/dashboard/sessions" },
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-token": token,
|
||||
}),
|
||||
cookies: {
|
||||
get: () => ({ value: token }),
|
||||
},
|
||||
clone: () => ({
|
||||
json: async () => ({ csrfToken: token }),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("should block POST request with mismatched CSRF tokens", async () => {
|
||||
const headerToken = generateCSRFToken();
|
||||
const cookieToken = generateCSRFToken();
|
||||
|
||||
const request = {
|
||||
method: "POST",
|
||||
nextUrl: { pathname: "/api/dashboard/sessions" },
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-token": headerToken,
|
||||
}),
|
||||
cookies: {
|
||||
get: () => ({ value: cookieToken }),
|
||||
},
|
||||
clone: () => ({
|
||||
json: async () => ({ csrfToken: headerToken }),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should protect all state-changing methods", async () => {
|
||||
const methods = ["POST", "PUT", "DELETE", "PATCH"];
|
||||
|
||||
for (const method of methods) {
|
||||
const request = {
|
||||
method,
|
||||
nextUrl: { pathname: "/api/trpc/test" },
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
cookies: {
|
||||
get: () => undefined,
|
||||
},
|
||||
clone: () => ({
|
||||
json: async () => ({}),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Protected Endpoints", () => {
|
||||
const protectedPaths = [
|
||||
"/api/auth/signin",
|
||||
"/api/register",
|
||||
"/api/forgot-password",
|
||||
"/api/reset-password",
|
||||
"/api/dashboard/sessions",
|
||||
"/api/platform/companies",
|
||||
"/api/trpc/test",
|
||||
];
|
||||
|
||||
protectedPaths.forEach((path) => {
|
||||
it(`should protect ${path} endpoint`, async () => {
|
||||
const request = {
|
||||
method: "POST",
|
||||
nextUrl: { pathname: path },
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
cookies: {
|
||||
get: () => undefined,
|
||||
},
|
||||
clone: () => ({
|
||||
json: async () => ({}),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle malformed requests gracefully", async () => {
|
||||
const request = {
|
||||
method: "POST",
|
||||
nextUrl: { pathname: "/api/dashboard/sessions" },
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
cookies: {
|
||||
get: () => undefined,
|
||||
},
|
||||
clone: () => ({
|
||||
json: async () => {
|
||||
throw new Error("Malformed JSON");
|
||||
},
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should handle missing headers gracefully", async () => {
|
||||
const request = {
|
||||
method: "POST",
|
||||
nextUrl: { pathname: "/api/dashboard/sessions" },
|
||||
headers: new Headers(),
|
||||
cookies: {
|
||||
get: () => undefined,
|
||||
},
|
||||
clone: () => ({
|
||||
json: async () => ({}),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const response = await csrfProtectionMiddleware(request);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Integration tests for CSV import workflow
|
||||
*
|
||||
*
|
||||
* Tests the complete end-to-end flow of CSV import:
|
||||
* 1. CSV file fetching from URL
|
||||
* 2. Parsing and validation of CSV data
|
||||
@ -109,7 +109,7 @@ session2,user2,nl,NL,192.168.1.2,neutral,3,2024-01-15T11:00:00Z,2024-01-15T11:20
|
||||
expect(options.headers.Authorization).toBe(
|
||||
`Basic ${Buffer.from("testuser:testpass").toString("base64")}`
|
||||
);
|
||||
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: async () => mockCsvData,
|
||||
@ -185,7 +185,7 @@ session2,user2,nl,NL,192.168.1.2,neutral,3,2024-01-15T11:00:00Z,2024-01-15T11:20
|
||||
|
||||
it("should handle invalid CSV format", async () => {
|
||||
const invalidCsv = "invalid,csv,data\nwithout,proper,headers";
|
||||
|
||||
|
||||
const fetchMock = await import("node-fetch");
|
||||
vi.mocked(fetchMock.default).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@ -347,13 +347,13 @@ session4,user4,en,US,192.168.1.4,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:3
|
||||
it("should handle large CSV files efficiently", async () => {
|
||||
// Generate large CSV with 1000 rows
|
||||
const largeCSVRows = ["sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary"];
|
||||
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
largeCSVRows.push(
|
||||
`session${i},user${i},en,US,192.168.1.${i % 255},positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,false,false,Session ${i}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const largeCsv = largeCSVRows.join("\n");
|
||||
|
||||
const fetchMock = await import("node-fetch");
|
||||
|
||||
238
tests/integration/password-reset-flow.test.ts
Normal file
238
tests/integration/password-reset-flow.test.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Mock dependencies before importing auth router
|
||||
vi.mock("../../lib/prisma", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("bcryptjs", () => ({
|
||||
default: {
|
||||
hash: vi.fn().mockResolvedValue("hashed-password"),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Password Reset Flow Integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Forgot Password Flow", () => {
|
||||
it("should generate secure tokens during password reset request", async () => {
|
||||
// Import after mocks are set up
|
||||
const { authRouter } = await import("../../server/routers/auth");
|
||||
const { prisma } = await import("../../lib/prisma");
|
||||
|
||||
const testUser = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
password: "hashed-password",
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
companyId: "company-123",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(testUser);
|
||||
|
||||
let capturedToken: string | undefined;
|
||||
vi.mocked(prisma.user.update).mockImplementation(async ({ data }) => {
|
||||
capturedToken = data.resetToken;
|
||||
return {
|
||||
...testUser,
|
||||
resetToken: data.resetToken,
|
||||
resetTokenExpiry: data.resetTokenExpiry,
|
||||
};
|
||||
});
|
||||
|
||||
// Create a mock tRPC context
|
||||
const ctx = {
|
||||
prisma,
|
||||
session: null,
|
||||
};
|
||||
|
||||
// Call the forgotPassword procedure directly
|
||||
const result = await authRouter
|
||||
.createCaller(ctx)
|
||||
.forgotPassword({ email: "test@example.com" });
|
||||
|
||||
expect(result.message).toContain("password reset link");
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
|
||||
// Verify the token was generated with proper security characteristics
|
||||
expect(capturedToken).toBeDefined();
|
||||
expect(capturedToken).toHaveLength(64);
|
||||
expect(capturedToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate different tokens for consecutive requests", async () => {
|
||||
// Import after mocks are set up
|
||||
const { authRouter } = await import("../../server/routers/auth");
|
||||
const { prisma } = await import("../../lib/prisma");
|
||||
|
||||
const testUser = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
password: "hashed-password",
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
companyId: "company-123",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const capturedTokens: string[] = [];
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(testUser);
|
||||
vi.mocked(prisma.user.update).mockImplementation(async ({ data }) => {
|
||||
capturedTokens.push(data.resetToken);
|
||||
return {
|
||||
...testUser,
|
||||
resetToken: data.resetToken,
|
||||
resetTokenExpiry: data.resetTokenExpiry,
|
||||
};
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
prisma,
|
||||
session: null,
|
||||
};
|
||||
|
||||
// Generate multiple tokens
|
||||
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
|
||||
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
|
||||
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
|
||||
|
||||
expect(capturedTokens).toHaveLength(3);
|
||||
expect(capturedTokens[0]).not.toBe(capturedTokens[1]);
|
||||
expect(capturedTokens[1]).not.toBe(capturedTokens[2]);
|
||||
expect(capturedTokens[0]).not.toBe(capturedTokens[2]);
|
||||
|
||||
// All tokens should be properly formatted
|
||||
capturedTokens.forEach(token => {
|
||||
expect(token).toHaveLength(64);
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reset Password Flow", () => {
|
||||
it("should accept secure tokens for password reset", async () => {
|
||||
// Import after mocks are set up
|
||||
const { authRouter } = await import("../../server/routers/auth");
|
||||
const { prisma } = await import("../../lib/prisma");
|
||||
|
||||
const secureToken = crypto.randomBytes(32).toString('hex');
|
||||
const futureDate = new Date(Date.now() + 3600000);
|
||||
|
||||
const userWithResetToken = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
password: "old-hashed-password",
|
||||
resetToken: secureToken,
|
||||
resetTokenExpiry: futureDate,
|
||||
companyId: "company-123",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(userWithResetToken);
|
||||
vi.mocked(prisma.user.update).mockResolvedValueOnce({
|
||||
...userWithResetToken,
|
||||
password: "new-hashed-password",
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
prisma,
|
||||
session: null,
|
||||
};
|
||||
|
||||
const result = await authRouter
|
||||
.createCaller(ctx)
|
||||
.resetPassword({
|
||||
token: secureToken,
|
||||
password: "NewSecurePassword123!",
|
||||
});
|
||||
|
||||
expect(result.message).toBe("Password reset successfully");
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
resetToken: secureToken,
|
||||
resetTokenExpiry: {
|
||||
gt: expect.any(Date),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject invalid token formats", async () => {
|
||||
// Import after mocks are set up
|
||||
const { authRouter } = await import("../../server/routers/auth");
|
||||
const { prisma } = await import("../../lib/prisma");
|
||||
|
||||
const invalidTokens = [
|
||||
"short",
|
||||
"invalid-chars-@#$",
|
||||
Math.random().toString(36).substring(2, 15), // Old weak format
|
||||
"0".repeat(63), // Wrong length
|
||||
"g".repeat(64), // Invalid hex chars
|
||||
];
|
||||
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
|
||||
const ctx = {
|
||||
prisma,
|
||||
session: null,
|
||||
};
|
||||
|
||||
for (const invalidToken of invalidTokens) {
|
||||
await expect(
|
||||
authRouter.createCaller(ctx).resetPassword({
|
||||
token: invalidToken,
|
||||
password: "NewSecurePassword123!",
|
||||
})
|
||||
).rejects.toThrow("Invalid or expired reset token");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Security Comparison", () => {
|
||||
it("should demonstrate improvement over weak Math.random() tokens", () => {
|
||||
// Generate tokens using both methods
|
||||
const secureTokens = Array.from({ length: 100 }, () =>
|
||||
crypto.randomBytes(32).toString('hex')
|
||||
);
|
||||
|
||||
const weakTokens = Array.from({ length: 100 }, () =>
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
|
||||
// Secure tokens should be longer
|
||||
const avgSecureLength = secureTokens.reduce((sum, t) => sum + t.length, 0) / secureTokens.length;
|
||||
const avgWeakLength = weakTokens.reduce((sum, t) => sum + t.length, 0) / weakTokens.length;
|
||||
|
||||
expect(avgSecureLength).toBeGreaterThan(avgWeakLength * 4);
|
||||
|
||||
// Secure tokens should have no collisions
|
||||
expect(new Set(secureTokens).size).toBe(secureTokens.length);
|
||||
|
||||
// Weak tokens might have collisions with enough samples
|
||||
// but more importantly, they're predictable
|
||||
secureTokens.forEach(token => {
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
156
tests/integration/security-headers-basic.test.ts
Normal file
156
tests/integration/security-headers-basic.test.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("Security Headers Configuration", () => {
|
||||
describe("Next.js Config Validation", () => {
|
||||
it("should have valid security headers configuration", async () => {
|
||||
// Import the Next.js config
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
|
||||
expect(nextConfig.default).toBeDefined();
|
||||
expect(nextConfig.default.headers).toBeDefined();
|
||||
expect(typeof nextConfig.default.headers).toBe("function");
|
||||
});
|
||||
|
||||
it("should generate expected headers structure", async () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
const headers = await nextConfig.default.headers();
|
||||
|
||||
expect(Array.isArray(headers)).toBe(true);
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the main security headers configuration
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
expect(securityConfig).toBeDefined();
|
||||
|
||||
if (securityConfig) {
|
||||
const headerNames = securityConfig.headers.map(h => h.key);
|
||||
|
||||
// Check required security headers are present
|
||||
expect(headerNames).toContain("X-Content-Type-Options");
|
||||
expect(headerNames).toContain("X-Frame-Options");
|
||||
expect(headerNames).toContain("X-XSS-Protection");
|
||||
expect(headerNames).toContain("Referrer-Policy");
|
||||
expect(headerNames).toContain("Content-Security-Policy");
|
||||
expect(headerNames).toContain("Permissions-Policy");
|
||||
expect(headerNames).toContain("X-DNS-Prefetch-Control");
|
||||
}
|
||||
});
|
||||
|
||||
it("should have correct security header values", async () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
const headers = await nextConfig.default.headers();
|
||||
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
|
||||
if (securityConfig) {
|
||||
const headerMap = new Map(securityConfig.headers.map(h => [h.key, h.value]));
|
||||
|
||||
expect(headerMap.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(headerMap.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(headerMap.get("X-XSS-Protection")).toBe("1; mode=block");
|
||||
expect(headerMap.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
|
||||
expect(headerMap.get("X-DNS-Prefetch-Control")).toBe("off");
|
||||
|
||||
// CSP should contain essential directives
|
||||
const csp = headerMap.get("Content-Security-Policy");
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
|
||||
// Permissions Policy should restrict dangerous features
|
||||
const permissions = headerMap.get("Permissions-Policy");
|
||||
expect(permissions).toContain("camera=()");
|
||||
expect(permissions).toContain("microphone=()");
|
||||
expect(permissions).toContain("geolocation=()");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle HSTS header based on environment", async () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
|
||||
// Test production environment
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
const prodHeaders = await nextConfig.default.headers();
|
||||
const hstsConfig = prodHeaders.find(h =>
|
||||
h.headers.some(header => header.key === "Strict-Transport-Security")
|
||||
);
|
||||
|
||||
if (hstsConfig) {
|
||||
const hstsHeader = hstsConfig.headers.find(h => h.key === "Strict-Transport-Security");
|
||||
expect(hstsHeader?.value).toBe("max-age=31536000; includeSubDomains; preload");
|
||||
}
|
||||
|
||||
// Test development environment
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const devHeaders = await nextConfig.default.headers();
|
||||
const devHstsConfig = devHeaders.find(h =>
|
||||
h.headers.some(header => header.key === "Strict-Transport-Security")
|
||||
);
|
||||
|
||||
// In development, HSTS header array should be empty
|
||||
if (devHstsConfig) {
|
||||
expect(devHstsConfig.headers.length).toBe(0);
|
||||
}
|
||||
|
||||
// Restore original environment
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSP Directive Validation", () => {
|
||||
it("should have comprehensive CSP directives", async () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
const headers = await nextConfig.default.headers();
|
||||
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
const cspHeader = securityConfig?.headers.find(h => h.key === "Content-Security-Policy");
|
||||
|
||||
expect(cspHeader).toBeDefined();
|
||||
|
||||
if (cspHeader) {
|
||||
const csp = cspHeader.value;
|
||||
|
||||
// Essential security directives
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
expect(csp).toContain("base-uri 'self'");
|
||||
expect(csp).toContain("form-action 'self'");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
expect(csp).toContain("upgrade-insecure-requests");
|
||||
|
||||
// Next.js compatibility directives
|
||||
expect(csp).toContain("script-src 'self' 'unsafe-eval' 'unsafe-inline'");
|
||||
expect(csp).toContain("style-src 'self' 'unsafe-inline'");
|
||||
expect(csp).toContain("img-src 'self' data: https:");
|
||||
expect(csp).toContain("font-src 'self' data:");
|
||||
expect(csp).toContain("connect-src 'self' https:");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Permissions Policy Validation", () => {
|
||||
it("should restrict dangerous browser features", async () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
const headers = await nextConfig.default.headers();
|
||||
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
const permissionsHeader = securityConfig?.headers.find(h => h.key === "Permissions-Policy");
|
||||
|
||||
expect(permissionsHeader).toBeDefined();
|
||||
|
||||
if (permissionsHeader) {
|
||||
const permissions = permissionsHeader.value;
|
||||
|
||||
// Should disable privacy-sensitive features
|
||||
expect(permissions).toContain("camera=()");
|
||||
expect(permissions).toContain("microphone=()");
|
||||
expect(permissions).toContain("geolocation=()");
|
||||
expect(permissions).toContain("interest-cohort=()");
|
||||
expect(permissions).toContain("browsing-topics=()");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Integration tests for session processing workflow
|
||||
*
|
||||
*
|
||||
* Tests the complete end-to-end flow of session processing:
|
||||
* 1. Import processing (SessionImport → Session)
|
||||
* 2. Transcript fetching and parsing
|
||||
|
||||
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),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
240
tests/unit/csrf.test.ts
Normal file
240
tests/unit/csrf.test.ts
Normal file
@ -0,0 +1,240 @@
|
||||
/**
|
||||
* CSRF Protection Unit Tests
|
||||
*
|
||||
* Tests for CSRF token generation, validation, and protection mechanisms.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { generateCSRFToken, verifyCSRFToken, CSRFProtection, CSRF_CONFIG } from "../../lib/csrf";
|
||||
|
||||
// Mock Next.js modules
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("CSRF Protection", () => {
|
||||
describe("Token Generation and Verification", () => {
|
||||
it("should generate a valid CSRF token", () => {
|
||||
const token = generateCSRFToken();
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
expect(token.includes(":")).toBe(true);
|
||||
});
|
||||
|
||||
it("should verify a valid CSRF token", () => {
|
||||
const token = generateCSRFToken();
|
||||
const isValid = verifyCSRFToken(token);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject an invalid CSRF token", () => {
|
||||
const isValid = verifyCSRFToken("invalid-token");
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject an empty CSRF token", () => {
|
||||
const isValid = verifyCSRFToken("");
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject a malformed CSRF token", () => {
|
||||
const isValid = verifyCSRFToken("malformed:token:with:extra:parts");
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSRFProtection Class", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should generate token response with correct structure", () => {
|
||||
const response = CSRFProtection.generateTokenResponse();
|
||||
|
||||
expect(response).toHaveProperty("token");
|
||||
expect(response).toHaveProperty("cookie");
|
||||
expect(response.cookie).toHaveProperty("name", CSRF_CONFIG.cookieName);
|
||||
expect(response.cookie).toHaveProperty("value");
|
||||
expect(response.cookie).toHaveProperty("options");
|
||||
expect(response.cookie.options).toHaveProperty("httpOnly", true);
|
||||
expect(response.cookie.options).toHaveProperty("path", "/");
|
||||
});
|
||||
|
||||
it("should validate GET requests without CSRF token", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "GET",
|
||||
}) as any;
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate HEAD requests without CSRF token", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "HEAD",
|
||||
}) as any;
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate OPTIONS requests without CSRF token", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "OPTIONS",
|
||||
}) as any;
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject POST request without CSRF token", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
}) as any;
|
||||
|
||||
// Mock cookies method to return no token
|
||||
Object.defineProperty(request, "cookies", {
|
||||
value: {
|
||||
get: vi.fn(() => undefined),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("CSRF token missing");
|
||||
});
|
||||
|
||||
it("should validate POST request with valid CSRF token", async () => {
|
||||
const token = generateCSRFToken();
|
||||
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[CSRF_CONFIG.headerName]: token,
|
||||
},
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
}) as any;
|
||||
|
||||
// Mock cookies method to return the same token
|
||||
Object.defineProperty(request, "cookies", {
|
||||
value: {
|
||||
get: vi.fn(() => ({ value: token })),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject POST request with mismatched CSRF tokens", async () => {
|
||||
const headerToken = generateCSRFToken();
|
||||
const cookieToken = generateCSRFToken();
|
||||
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[CSRF_CONFIG.headerName]: headerToken,
|
||||
},
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
}) as any;
|
||||
|
||||
// Mock cookies method to return different token
|
||||
Object.defineProperty(request, "cookies", {
|
||||
value: {
|
||||
get: vi.fn(() => ({ value: cookieToken })),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("mismatch");
|
||||
});
|
||||
|
||||
it("should handle form data CSRF token", async () => {
|
||||
const token = generateCSRFToken();
|
||||
const formData = new FormData();
|
||||
formData.append("csrf_token", token);
|
||||
formData.append("data", "test");
|
||||
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
body: formData,
|
||||
}) as any;
|
||||
|
||||
// Mock cookies method to return the same token
|
||||
Object.defineProperty(request, "cookies", {
|
||||
value: {
|
||||
get: vi.fn(() => ({ value: token })),
|
||||
},
|
||||
});
|
||||
|
||||
// Mock clone method to return a request that can be parsed
|
||||
Object.defineProperty(request, "clone", {
|
||||
value: vi.fn(() => ({
|
||||
formData: async () => formData,
|
||||
})),
|
||||
});
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle JSON body CSRF token", async () => {
|
||||
const token = generateCSRFToken();
|
||||
const bodyData = { csrfToken: token, data: "test" };
|
||||
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(bodyData),
|
||||
}) as any;
|
||||
|
||||
// Mock cookies method to return the same token
|
||||
Object.defineProperty(request, "cookies", {
|
||||
value: {
|
||||
get: vi.fn(() => ({ value: token })),
|
||||
},
|
||||
});
|
||||
|
||||
// Mock clone method to return a request that can be parsed
|
||||
Object.defineProperty(request, "clone", {
|
||||
value: vi.fn(() => ({
|
||||
json: async () => bodyData,
|
||||
})),
|
||||
});
|
||||
|
||||
const result = await CSRFProtection.validateRequest(request);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSRF Configuration", () => {
|
||||
it("should have correct configuration values", () => {
|
||||
expect(CSRF_CONFIG.cookieName).toBe("csrf-token");
|
||||
expect(CSRF_CONFIG.headerName).toBe("x-csrf-token");
|
||||
expect(CSRF_CONFIG.cookie.httpOnly).toBe(true);
|
||||
expect(CSRF_CONFIG.cookie.sameSite).toBe("lax");
|
||||
expect(CSRF_CONFIG.cookie.maxAge).toBe(60 * 60 * 24); // 24 hours
|
||||
});
|
||||
|
||||
it("should use secure cookies in production", () => {
|
||||
// This would depend on NODE_ENV, which is set in the config
|
||||
expect(typeof CSRF_CONFIG.cookie.secure).toBe("boolean");
|
||||
});
|
||||
});
|
||||
});
|
||||
373
tests/unit/http-security-headers.test.ts
Normal file
373
tests/unit/http-security-headers.test.ts
Normal file
@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Mock Next.js response for testing headers
|
||||
const createMockResponse = (headers: Record<string, string> = {}) => {
|
||||
return new Response(null, { headers });
|
||||
};
|
||||
|
||||
describe("HTTP Security Headers", () => {
|
||||
describe("Security Header Configuration", () => {
|
||||
it("should include X-Content-Type-Options header", () => {
|
||||
const response = createMockResponse({
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("should include X-Frame-Options header for clickjacking protection", () => {
|
||||
const response = createMockResponse({
|
||||
"X-Frame-Options": "DENY",
|
||||
});
|
||||
|
||||
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
});
|
||||
|
||||
it("should include X-XSS-Protection header for legacy browser protection", () => {
|
||||
const response = createMockResponse({
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
});
|
||||
|
||||
expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block");
|
||||
});
|
||||
|
||||
it("should include Referrer-Policy header for privacy protection", () => {
|
||||
const response = createMockResponse({
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
});
|
||||
|
||||
expect(response.headers.get("Referrer-Policy")).toBe(
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
});
|
||||
|
||||
it("should include X-DNS-Prefetch-Control header", () => {
|
||||
const response = createMockResponse({
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
});
|
||||
|
||||
expect(response.headers.get("X-DNS-Prefetch-Control")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content Security Policy", () => {
|
||||
it("should include a comprehensive CSP header", () => {
|
||||
const expectedCsp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": expectedCsp,
|
||||
});
|
||||
|
||||
expect(response.headers.get("Content-Security-Policy")).toBe(expectedCsp);
|
||||
});
|
||||
|
||||
it("should have restrictive default-src policy", () => {
|
||||
const csp = "default-src 'self'";
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": csp,
|
||||
});
|
||||
|
||||
const cspValue = response.headers.get("Content-Security-Policy");
|
||||
expect(cspValue).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
it("should allow inline styles for TailwindCSS compatibility", () => {
|
||||
const csp = "style-src 'self' 'unsafe-inline'";
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": csp,
|
||||
});
|
||||
|
||||
const cspValue = response.headers.get("Content-Security-Policy");
|
||||
expect(cspValue).toContain("style-src 'self' 'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("should prevent object embedding", () => {
|
||||
const csp = "object-src 'none'";
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": csp,
|
||||
});
|
||||
|
||||
const cspValue = response.headers.get("Content-Security-Policy");
|
||||
expect(cspValue).toContain("object-src 'none'");
|
||||
});
|
||||
|
||||
it("should prevent framing with frame-ancestors", () => {
|
||||
const csp = "frame-ancestors 'none'";
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": csp,
|
||||
});
|
||||
|
||||
const cspValue = response.headers.get("Content-Security-Policy");
|
||||
expect(cspValue).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it("should upgrade insecure requests", () => {
|
||||
const csp = "upgrade-insecure-requests";
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": csp,
|
||||
});
|
||||
|
||||
const cspValue = response.headers.get("Content-Security-Policy");
|
||||
expect(cspValue).toContain("upgrade-insecure-requests");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Permissions Policy", () => {
|
||||
it("should include restrictive Permissions-Policy header", () => {
|
||||
const expectedPolicy = [
|
||||
"camera=()",
|
||||
"microphone=()",
|
||||
"geolocation=()",
|
||||
"interest-cohort=()",
|
||||
"browsing-topics=()",
|
||||
].join(", ");
|
||||
|
||||
const response = createMockResponse({
|
||||
"Permissions-Policy": expectedPolicy,
|
||||
});
|
||||
|
||||
expect(response.headers.get("Permissions-Policy")).toBe(expectedPolicy);
|
||||
});
|
||||
|
||||
it("should disable camera access", () => {
|
||||
const policy = "camera=()";
|
||||
const response = createMockResponse({
|
||||
"Permissions-Policy": policy,
|
||||
});
|
||||
|
||||
const policyValue = response.headers.get("Permissions-Policy");
|
||||
expect(policyValue).toContain("camera=()");
|
||||
});
|
||||
|
||||
it("should disable microphone access", () => {
|
||||
const policy = "microphone=()";
|
||||
const response = createMockResponse({
|
||||
"Permissions-Policy": policy,
|
||||
});
|
||||
|
||||
const policyValue = response.headers.get("Permissions-Policy");
|
||||
expect(policyValue).toContain("microphone=()");
|
||||
});
|
||||
|
||||
it("should disable geolocation access", () => {
|
||||
const policy = "geolocation=()";
|
||||
const response = createMockResponse({
|
||||
"Permissions-Policy": policy,
|
||||
});
|
||||
|
||||
const policyValue = response.headers.get("Permissions-Policy");
|
||||
expect(policyValue).toContain("geolocation=()");
|
||||
});
|
||||
|
||||
it("should disable interest-cohort for privacy", () => {
|
||||
const policy = "interest-cohort=()";
|
||||
const response = createMockResponse({
|
||||
"Permissions-Policy": policy,
|
||||
});
|
||||
|
||||
const policyValue = response.headers.get("Permissions-Policy");
|
||||
expect(policyValue).toContain("interest-cohort=()");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HSTS Configuration", () => {
|
||||
it("should include HSTS header in production environment", () => {
|
||||
// Mock production environment
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
const response = createMockResponse({
|
||||
"Strict-Transport-Security":
|
||||
"max-age=31536000; includeSubDomains; preload",
|
||||
});
|
||||
|
||||
expect(response.headers.get("Strict-Transport-Security")).toBe(
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
);
|
||||
|
||||
// Restore original environment
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it("should have long max-age for HSTS", () => {
|
||||
const hstsValue = "max-age=31536000; includeSubDomains; preload";
|
||||
const response = createMockResponse({
|
||||
"Strict-Transport-Security": hstsValue,
|
||||
});
|
||||
|
||||
const hsts = response.headers.get("Strict-Transport-Security");
|
||||
expect(hsts).toContain("max-age=31536000"); // 1 year
|
||||
});
|
||||
|
||||
it("should include subdomains in HSTS", () => {
|
||||
const hstsValue = "max-age=31536000; includeSubDomains; preload";
|
||||
const response = createMockResponse({
|
||||
"Strict-Transport-Security": hstsValue,
|
||||
});
|
||||
|
||||
const hsts = response.headers.get("Strict-Transport-Security");
|
||||
expect(hsts).toContain("includeSubDomains");
|
||||
});
|
||||
|
||||
it("should be preload-ready for HSTS", () => {
|
||||
const hstsValue = "max-age=31536000; includeSubDomains; preload";
|
||||
const response = createMockResponse({
|
||||
"Strict-Transport-Security": hstsValue,
|
||||
});
|
||||
|
||||
const hsts = response.headers.get("Strict-Transport-Security");
|
||||
expect(hsts).toContain("preload");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header Security Validation", () => {
|
||||
it("should not expose server information", () => {
|
||||
const response = createMockResponse({});
|
||||
|
||||
// These headers should not be present or should be minimal
|
||||
expect(response.headers.get("Server")).toBeNull();
|
||||
expect(response.headers.get("X-Powered-By")).toBeNull();
|
||||
});
|
||||
|
||||
it("should have all required security headers present", () => {
|
||||
const requiredHeaders = [
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
"Referrer-Policy",
|
||||
"X-DNS-Prefetch-Control",
|
||||
"Content-Security-Policy",
|
||||
"Permissions-Policy",
|
||||
];
|
||||
|
||||
const allHeaders: Record<string, string> = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
"Content-Security-Policy": "default-src 'self'",
|
||||
"Permissions-Policy": "camera=()",
|
||||
};
|
||||
|
||||
const response = createMockResponse(allHeaders);
|
||||
|
||||
requiredHeaders.forEach((header) => {
|
||||
expect(response.headers.get(header)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should have proper header values for security", () => {
|
||||
const securityHeaders = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
};
|
||||
|
||||
const response = createMockResponse(securityHeaders);
|
||||
|
||||
// Verify each header has the expected security value
|
||||
Object.entries(securityHeaders).forEach(([header, expectedValue]) => {
|
||||
expect(response.headers.get(header)).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Development vs Production Headers", () => {
|
||||
it("should not include HSTS in development", () => {
|
||||
// Mock development environment
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const response = createMockResponse({});
|
||||
|
||||
// HSTS should not be present in development
|
||||
expect(response.headers.get("Strict-Transport-Security")).toBeNull();
|
||||
|
||||
// Restore original environment
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it("should include all other headers in development", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const devHeaders = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Content-Security-Policy": "default-src 'self'",
|
||||
"Permissions-Policy": "camera=()",
|
||||
};
|
||||
|
||||
const response = createMockResponse(devHeaders);
|
||||
|
||||
Object.entries(devHeaders).forEach(([header, expectedValue]) => {
|
||||
expect(response.headers.get(header)).toBe(expectedValue);
|
||||
});
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Header Integration", () => {
|
||||
describe("CSP and Frame Protection Alignment", () => {
|
||||
it("should have consistent frame protection between CSP and X-Frame-Options", () => {
|
||||
// Both should prevent framing
|
||||
const cspResponse = createMockResponse({
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
});
|
||||
|
||||
const xFrameResponse = createMockResponse({
|
||||
"X-Frame-Options": "DENY",
|
||||
});
|
||||
|
||||
expect(cspResponse.headers.get("Content-Security-Policy")).toContain(
|
||||
"frame-ancestors 'none'"
|
||||
);
|
||||
expect(xFrameResponse.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Next.js Compatibility", () => {
|
||||
it("should allow necessary Next.js functionality in CSP", () => {
|
||||
const csp = "script-src 'self' 'unsafe-eval' 'unsafe-inline'";
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": csp,
|
||||
});
|
||||
|
||||
const cspValue = response.headers.get("Content-Security-Policy");
|
||||
|
||||
// Next.js requires unsafe-eval for dev tools and unsafe-inline for some functionality
|
||||
expect(cspValue).toContain("'unsafe-eval'");
|
||||
expect(cspValue).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("should allow TailwindCSS inline styles in CSP", () => {
|
||||
const csp = "style-src 'self' 'unsafe-inline'";
|
||||
const response = createMockResponse({
|
||||
"Content-Security-Policy": csp,
|
||||
});
|
||||
|
||||
const cspValue = response.headers.get("Content-Security-Policy");
|
||||
expect(cspValue).toContain("style-src 'self' 'unsafe-inline'");
|
||||
});
|
||||
});
|
||||
});
|
||||
142
tests/unit/password-reset-token.test.ts
Normal file
142
tests/unit/password-reset-token.test.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Mock crypto to test both real and mocked behavior
|
||||
const originalRandomBytes = crypto.randomBytes;
|
||||
|
||||
describe("Password Reset Token Security", () => {
|
||||
beforeEach(() => {
|
||||
// Restore original crypto function for these tests
|
||||
crypto.randomBytes = originalRandomBytes;
|
||||
});
|
||||
|
||||
describe("Token Generation Security Properties", () => {
|
||||
it("should generate tokens with 64 characters (32 bytes as hex)", () => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
expect(token).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("should generate unique tokens on each call", () => {
|
||||
const token1 = crypto.randomBytes(32).toString('hex');
|
||||
const token2 = crypto.randomBytes(32).toString('hex');
|
||||
const token3 = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
expect(token2).not.toBe(token3);
|
||||
expect(token1).not.toBe(token3);
|
||||
});
|
||||
|
||||
it("should generate tokens with proper entropy (no obvious patterns)", () => {
|
||||
const tokens = new Set();
|
||||
const numTokens = 100;
|
||||
|
||||
// Generate multiple tokens to check for patterns
|
||||
for (let i = 0; i < numTokens; i++) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
tokens.add(token);
|
||||
}
|
||||
|
||||
// All tokens should be unique
|
||||
expect(tokens.size).toBe(numTokens);
|
||||
});
|
||||
|
||||
it("should generate tokens with hex characters only", () => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const hexPattern = /^[0-9a-f]+$/;
|
||||
expect(token).toMatch(hexPattern);
|
||||
});
|
||||
|
||||
it("should have sufficient entropy to prevent brute force attacks", () => {
|
||||
// 32 bytes = 256 bits of entropy
|
||||
// This provides 2^256 possible combinations
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Verify we have the expected length for 256-bit security
|
||||
expect(token).toHaveLength(64);
|
||||
|
||||
// Verify character distribution is roughly uniform
|
||||
const charCounts = {};
|
||||
for (const char of token) {
|
||||
charCounts[char] = (charCounts[char] || 0) + 1;
|
||||
}
|
||||
|
||||
// Should have at least some variety in characters
|
||||
expect(Object.keys(charCounts).length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("should be significantly more secure than Math.random() approach", () => {
|
||||
// Generate tokens using both methods for comparison
|
||||
const secureToken = crypto.randomBytes(32).toString('hex');
|
||||
const weakToken = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Secure token should be much longer
|
||||
expect(secureToken.length).toBeGreaterThan(weakToken.length * 4);
|
||||
|
||||
// Secure token has proper hex format
|
||||
expect(secureToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// Weak token has predictable format
|
||||
expect(weakToken).toMatch(/^[0-9a-z]+$/);
|
||||
expect(weakToken.length).toBeLessThan(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Collision Resistance", () => {
|
||||
it("should have virtually zero probability of collision", () => {
|
||||
const tokens = new Set();
|
||||
const iterations = 10000;
|
||||
|
||||
// Generate many tokens to test collision resistance
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
expect(tokens.has(token)).toBe(false); // No collisions
|
||||
tokens.add(token);
|
||||
}
|
||||
|
||||
expect(tokens.size).toBe(iterations);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance Characteristics", () => {
|
||||
it("should generate tokens in reasonable time", () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate 1000 tokens
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Should complete in under 1 second
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Format Validation", () => {
|
||||
it("should always produce lowercase hex", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
expect(token).toBe(token.toLowerCase());
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("should never produce tokens starting with predictable patterns", () => {
|
||||
const tokens = [];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.push(crypto.randomBytes(32).toString('hex'));
|
||||
}
|
||||
|
||||
// Check that tokens don't all start with same character
|
||||
const firstChars = new Set(tokens.map(t => t[0]));
|
||||
expect(firstChars.size).toBeGreaterThan(1);
|
||||
|
||||
// Check that we don't have obvious patterns like all starting with '0'
|
||||
const zeroStart = tokens.filter(t => t.startsWith('0')).length;
|
||||
expect(zeroStart).toBeLessThan(tokens.length * 0.8); // Should be roughly 1/16
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user