diff options
Diffstat (limited to 'packages/trpc/routers/users.test.ts')
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 289 |
1 files changed, 287 insertions, 2 deletions
diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index 21ee3a7b..03e5d590 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -1,11 +1,46 @@ -import { assert, beforeEach, describe, expect, test } from "vitest"; +import { eq } from "drizzle-orm"; +import { assert, beforeEach, describe, expect, test, vi } from "vitest"; -import { assets, AssetTypes, bookmarks } from "@karakeep/db/schema"; +import { + assets, + AssetTypes, + bookmarks, + passwordResetTokens, + users, +} from "@karakeep/db/schema"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import type { CustomTestContext } from "../testUtils"; +import * as emailModule from "../email"; import { defaultBeforeEach, getApiCaller } from "../testUtils"; +// Mock server config with all required properties - MUST be before any imports that use config +vi.mock("@karakeep/shared/config", async (original) => { + const mod = (await original()) as typeof import("@karakeep/shared/config"); + return { + ...mod, + default: { + ...mod.default, + email: { + smtp: { + host: "test-smtp.example.com", + port: 587, + secure: false, + user: "test@example.com", + password: "test-password", + from: "test@example.com", + }, + }, + }, + }; +}); + +// Mock email functions +vi.mock("../email", () => ({ + sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined), + sendVerificationEmail: vi.fn().mockResolvedValue(undefined), +})); + beforeEach<CustomTestContext>(defaultBeforeEach(false)); describe("User Routes", () => { @@ -475,4 +510,254 @@ describe("User Routes", () => { ), ).toBe(true); }); + + describe("Password Reset", () => { + test<CustomTestContext>("forgotPassword - successful email sending", async ({ + unauthedAPICaller, + }) => { + // Create a user first + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "reset@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // With mocked email service, this should succeed + const result = await unauthedAPICaller.users.forgotPassword({ + email: "reset@test.com", + }); + + expect(result.success).toBe(true); + + // Verify that the email function was called with correct parameters + expect(emailModule.sendPasswordResetEmail).toHaveBeenCalledWith( + "reset@test.com", + "Test User", + user.id, + ); + }); + + test<CustomTestContext>("forgotPassword - non-existing user", async ({ + unauthedAPICaller, + }) => { + // Should not reveal if user exists or not + const result = await unauthedAPICaller.users.forgotPassword({ + email: "nonexistent@test.com", + }); + + expect(result.success).toBe(true); + }); + + test<CustomTestContext>("forgotPassword - OAuth user (no password)", async ({ + db, + unauthedAPICaller, + }) => { + // Create a user without password (OAuth user) + await db.insert(users).values({ + name: "OAuth User", + email: "oauth@test.com", + password: null, + }); + + // Should not send reset email for OAuth users + const result = await unauthedAPICaller.users.forgotPassword({ + email: "oauth@test.com", + }); + + expect(result.success).toBe(true); + }); + + test<CustomTestContext>("resetPassword - valid token", async ({ + db, + unauthedAPICaller, + }) => { + // Create a user + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "validreset@test.com", + password: "oldpass123", + confirmPassword: "oldpass123", + }); + + // Create a password reset token directly in the database + const token = "valid-reset-token"; + const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now + + await db.insert(passwordResetTokens).values({ + userId: user.id, + token, + expires, + }); + + // Reset the password + const result = await unauthedAPICaller.users.resetPassword({ + token, + newPassword: "newpass123", + }); + + expect(result.success).toBe(true); + + // Verify the token was consumed (deleted) + const remainingTokens = await db + .select() + .from(passwordResetTokens) + .where(eq(passwordResetTokens.token, token)); + + expect(remainingTokens).toHaveLength(0); + + // The password reset was successful if we got here without errors + }); + + test<CustomTestContext>("resetPassword - invalid token", async ({ + unauthedAPICaller, + }) => { + await expect( + unauthedAPICaller.users.resetPassword({ + token: "invalid-token", + newPassword: "newpass123", + }), + ).rejects.toThrow(/Invalid or expired reset token/); + }); + + test<CustomTestContext>("resetPassword - expired token", async ({ + db, + unauthedAPICaller, + }) => { + // Create a user + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "expiredtoken@test.com", + password: "oldpass123", + confirmPassword: "oldpass123", + }); + + // Create an expired password reset token + const token = "expired-reset-token"; + const expires = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago (expired) + + await db.insert(passwordResetTokens).values({ + userId: user.id, + token, + expires, + }); + + await expect( + unauthedAPICaller.users.resetPassword({ + token, + newPassword: "newpass123", + }), + ).rejects.toThrow(/Invalid or expired reset token/); + + // Verify the expired token was cleaned up + const remainingTokens = await db + .select() + .from(passwordResetTokens) + .where(eq(passwordResetTokens.token, token)); + + expect(remainingTokens).toHaveLength(0); + }); + + test<CustomTestContext>("resetPassword - user not found", async ({ + db, + unauthedAPICaller, + }) => { + // Create a user first, then delete them to create an orphaned token + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "orphaned@test.com", + password: "oldpass123", + confirmPassword: "oldpass123", + }); + + // Create a password reset token + const token = "orphaned-token"; + const expires = new Date(Date.now() + 60 * 60 * 1000); + + await db.insert(passwordResetTokens).values({ + userId: user.id, + token, + expires, + }); + + // Delete the user to make the token orphaned + // Due to foreign key cascade, this will also delete the token + // So we expect "Invalid or expired reset token" instead of "User not found" + await db.delete(users).where(eq(users.id, user.id)); + + await expect( + unauthedAPICaller.users.resetPassword({ + token, + newPassword: "newpass123", + }), + ).rejects.toThrow(/Invalid or expired reset token/); + }); + test<CustomTestContext>("resetPassword - password validation", async ({ + db, + unauthedAPICaller, + }) => { + // Create a user + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "validation@test.com", + password: "oldpass123", + confirmPassword: "oldpass123", + }); + + // Create a password reset token + const token = "validation-token"; + const expires = new Date(Date.now() + 60 * 60 * 1000); + + await db.insert(passwordResetTokens).values({ + userId: user.id, + token, + expires, + }); + + // Try to reset with a password that's too short + await expect( + unauthedAPICaller.users.resetPassword({ + token, + newPassword: "123", // Too short + }), + ).rejects.toThrow(); + }); + + test<CustomTestContext>("resetPassword - token reuse prevention", async ({ + db, + unauthedAPICaller, + }) => { + // Create a user + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "reuse@test.com", + password: "oldpass123", + confirmPassword: "oldpass123", + }); + + // Create a password reset token + const token = "reuse-token"; + const expires = new Date(Date.now() + 60 * 60 * 1000); + + await db.insert(passwordResetTokens).values({ + userId: user.id, + token, + expires, + }); + + // Use the token once + await unauthedAPICaller.users.resetPassword({ + token, + newPassword: "newpass123", + }); + + // Try to use the same token again + await expect( + unauthedAPICaller.users.resetPassword({ + token, + newPassword: "anotherpass123", + }), + ).rejects.toThrow(/Invalid or expired reset token/); + }); + }); }); |
