Files
livedash-node/tests/integration/password-reset-flow.test.ts

238 lines
7.3 KiB
TypeScript

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}$/);
});
});
});
});