mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 21:32:08 +01:00
feat: implement comprehensive CSRF protection
This commit is contained in:
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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user