From d5d362e52b3ff34967e45b3c971ba17762acdf09 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 1 Dec 2025 09:41:25 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=AC=20refactor:=20Normalize=20Email=20?= =?UTF-8?q?Handling=20in=20User=20Methods=20(#10743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated the `findUser` method to normalize email fields to lowercase and trimmed whitespace for case-insensitive matching. - Enhanced the `normalizeEmailInCriteria` function to handle email normalization in search criteria, including `` conditions. - Added tests to ensure email normalization works correctly across various scenarios, including case differences and whitespace handling. --- api/server/services/PermissionService.js | 2 +- api/strategies/socialLogin.test.js | 1 + packages/api/src/auth/openid.spec.ts | 28 + .../data-schemas/src/methods/token.spec.ts | 167 +++++ packages/data-schemas/src/methods/token.ts | 6 +- .../src/methods/user.methods.spec.ts | 623 ++++++++++++++++++ packages/data-schemas/src/methods/user.ts | 24 +- 7 files changed, 847 insertions(+), 4 deletions(-) create mode 100644 packages/data-schemas/src/methods/user.methods.spec.ts diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 4705eadb53..58afffec4a 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -292,7 +292,7 @@ const ensurePrincipalExists = async function (principal) { let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource }); if (!existingUser) { - existingUser = await findUser({ email: principal.email.toLowerCase() }); + existingUser = await findUser({ email: principal.email }); } if (existingUser) { diff --git a/api/strategies/socialLogin.test.js b/api/strategies/socialLogin.test.js index 11ada17975..ba4778c8b1 100644 --- a/api/strategies/socialLogin.test.js +++ b/api/strategies/socialLogin.test.js @@ -172,6 +172,7 @@ describe('socialLogin', () => { /** Verify both searches happened */ expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId }); + /** Email passed as-is; findUser implementation handles case normalization */ expect(findUser).toHaveBeenNthCalledWith(2, { email: email }); expect(findUser).toHaveBeenCalledTimes(2); diff --git a/packages/api/src/auth/openid.spec.ts b/packages/api/src/auth/openid.spec.ts index 032c62e580..7349508ce1 100644 --- a/packages/api/src/auth/openid.spec.ts +++ b/packages/api/src/auth/openid.spec.ts @@ -394,6 +394,34 @@ describe('findOpenIDUser', () => { expect(mockFindUser).toHaveBeenCalledWith({ email: 'user@example.com' }); }); + it('should pass email to findUser for case-insensitive lookup (findUser handles normalization)', async () => { + const mockUser: IUser = { + _id: 'user123', + provider: 'openid', + openidId: 'openid_456', + email: 'user@example.com', + username: 'testuser', + } as IUser; + + mockFindUser + .mockResolvedValueOnce(null) // Primary condition fails + .mockResolvedValueOnce(mockUser); // Email search succeeds + + const result = await findOpenIDUser({ + openidId: 'openid_123', + findUser: mockFindUser, + email: 'User@Example.COM', + }); + + /** Email is passed as-is; findUser implementation handles normalization */ + expect(mockFindUser).toHaveBeenNthCalledWith(2, { email: 'User@Example.COM' }); + expect(result).toEqual({ + user: mockUser, + error: null, + migration: false, + }); + }); + it('should handle findUser throwing an error', async () => { mockFindUser.mockRejectedValueOnce(new Error('Database error')); diff --git a/packages/data-schemas/src/methods/token.spec.ts b/packages/data-schemas/src/methods/token.spec.ts index bec18a419e..9658c7976a 100644 --- a/packages/data-schemas/src/methods/token.spec.ts +++ b/packages/data-schemas/src/methods/token.spec.ts @@ -617,4 +617,171 @@ describe('Token Methods - Detailed Tests', () => { expect(remainingTokens.find((t) => t.token === 'email-verify-token-2')).toBeUndefined(); }); }); + + describe('Email Normalization', () => { + let normUserId: mongoose.Types.ObjectId; + + beforeEach(async () => { + normUserId = new mongoose.Types.ObjectId(); + + // Create token with lowercase email (as stored in DB) + await Token.create({ + token: 'norm-token-1', + userId: normUserId, + email: 'john.doe@example.com', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 3600000), + }); + }); + + describe('findToken email normalization', () => { + test('should find token by email with different case (case-insensitive)', async () => { + const foundUpper = await methods.findToken({ email: 'JOHN.DOE@EXAMPLE.COM' }); + const foundMixed = await methods.findToken({ email: 'John.Doe@Example.COM' }); + const foundLower = await methods.findToken({ email: 'john.doe@example.com' }); + + expect(foundUpper).toBeDefined(); + expect(foundUpper?.token).toBe('norm-token-1'); + + expect(foundMixed).toBeDefined(); + expect(foundMixed?.token).toBe('norm-token-1'); + + expect(foundLower).toBeDefined(); + expect(foundLower?.token).toBe('norm-token-1'); + }); + + test('should find token by email with leading/trailing whitespace', async () => { + const foundWithSpaces = await methods.findToken({ email: ' john.doe@example.com ' }); + const foundWithTabs = await methods.findToken({ email: '\tjohn.doe@example.com\t' }); + + expect(foundWithSpaces).toBeDefined(); + expect(foundWithSpaces?.token).toBe('norm-token-1'); + + expect(foundWithTabs).toBeDefined(); + expect(foundWithTabs?.token).toBe('norm-token-1'); + }); + + test('should find token by email with both case difference and whitespace', async () => { + const found = await methods.findToken({ email: ' JOHN.DOE@EXAMPLE.COM ' }); + + expect(found).toBeDefined(); + expect(found?.token).toBe('norm-token-1'); + }); + + test('should find token with combined email and other criteria', async () => { + const found = await methods.findToken({ + userId: normUserId.toString(), + email: 'John.Doe@Example.COM', + }); + + expect(found).toBeDefined(); + expect(found?.token).toBe('norm-token-1'); + }); + }); + + describe('deleteTokens email normalization', () => { + test('should delete token by email with different case', async () => { + const result = await methods.deleteTokens({ email: 'JOHN.DOE@EXAMPLE.COM' }); + + expect(result.deletedCount).toBe(1); + + const remaining = await Token.find({}); + expect(remaining).toHaveLength(0); + }); + + test('should delete token by email with whitespace', async () => { + const result = await methods.deleteTokens({ email: ' john.doe@example.com ' }); + + expect(result.deletedCount).toBe(1); + + const remaining = await Token.find({}); + expect(remaining).toHaveLength(0); + }); + + test('should delete token by email with case and whitespace combined', async () => { + const result = await methods.deleteTokens({ email: ' John.Doe@EXAMPLE.COM ' }); + + expect(result.deletedCount).toBe(1); + + const remaining = await Token.find({}); + expect(remaining).toHaveLength(0); + }); + + test('should only delete matching token when using normalized email', async () => { + // Create additional token with different email + await Token.create({ + token: 'norm-token-2', + userId: new mongoose.Types.ObjectId(), + email: 'jane.doe@example.com', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 3600000), + }); + + const result = await methods.deleteTokens({ email: 'JOHN.DOE@EXAMPLE.COM' }); + + expect(result.deletedCount).toBe(1); + + const remaining = await Token.find({}); + expect(remaining).toHaveLength(1); + expect(remaining[0].email).toBe('jane.doe@example.com'); + }); + }); + + describe('Email verification flow with normalization', () => { + test('should handle OpenID provider email case mismatch scenario', async () => { + /** + * Simulate the exact bug scenario: + * 1. User registers with email stored as lowercase + * 2. OpenID provider returns email with different casing + * 3. System should still find and delete the correct token + */ + const userId = new mongoose.Types.ObjectId(); + + // Token created during registration (email stored lowercase) + await Token.create({ + token: 'verification-token', + userId: userId, + email: 'user@company.com', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 86400000), + }); + + // OpenID provider returns email with different case + const emailFromProvider = 'User@Company.COM'; + + // Should find the token despite case mismatch + const found = await methods.findToken({ email: emailFromProvider }); + expect(found).toBeDefined(); + expect(found?.token).toBe('verification-token'); + + // Should delete the token despite case mismatch + const deleted = await methods.deleteTokens({ email: emailFromProvider }); + expect(deleted.deletedCount).toBe(1); + }); + + test('should handle resend verification email with case mismatch', async () => { + const userId = new mongoose.Types.ObjectId(); + + // Old verification token + await Token.create({ + token: 'old-verification', + userId: userId, + email: 'john.smith@enterprise.com', + createdAt: new Date(Date.now() - 3600000), + expiresAt: new Date(Date.now() + 82800000), + }); + + // User requests resend with different email casing + const userInputEmail = ' John.Smith@ENTERPRISE.COM '; + + // Delete old tokens for this email + const deleted = await methods.deleteTokens({ email: userInputEmail }); + expect(deleted.deletedCount).toBe(1); + + // Verify token was actually deleted + const remaining = await Token.find({ userId }); + expect(remaining).toHaveLength(0); + }); + }); + }); }); diff --git a/packages/data-schemas/src/methods/token.ts b/packages/data-schemas/src/methods/token.ts index 5dfee869c0..e595067e6e 100644 --- a/packages/data-schemas/src/methods/token.ts +++ b/packages/data-schemas/src/methods/token.ts @@ -44,6 +44,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) { /** * Deletes all Token documents that match the provided token, user ID, or email. + * Email is automatically normalized to lowercase for case-insensitive matching. */ async function deleteTokens(query: TokenQuery): Promise { try { @@ -57,7 +58,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) { conditions.push({ token: query.token }); } if (query.email !== undefined) { - conditions.push({ email: query.email }); + conditions.push({ email: query.email.trim().toLowerCase() }); } if (query.identifier !== undefined) { conditions.push({ identifier: query.identifier }); @@ -81,6 +82,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) { /** * Finds a Token document that matches the provided query. + * Email is automatically normalized to lowercase for case-insensitive matching. */ async function findToken(query: TokenQuery, options?: QueryOptions): Promise { try { @@ -94,7 +96,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) { conditions.push({ token: query.token }); } if (query.email) { - conditions.push({ email: query.email }); + conditions.push({ email: query.email.trim().toLowerCase() }); } if (query.identifier) { conditions.push({ identifier: query.identifier }); diff --git a/packages/data-schemas/src/methods/user.methods.spec.ts b/packages/data-schemas/src/methods/user.methods.spec.ts new file mode 100644 index 0000000000..9fd33c5031 --- /dev/null +++ b/packages/data-schemas/src/methods/user.methods.spec.ts @@ -0,0 +1,623 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type * as t from '~/types'; +import { createUserMethods } from './user'; +import userSchema from '~/schema/user'; +import balanceSchema from '~/schema/balance'; + +/** Mocking crypto for generateToken */ +jest.mock('~/crypto', () => ({ + signPayload: jest.fn().mockResolvedValue('mocked-token'), +})); + +let mongoServer: MongoMemoryServer; +let User: mongoose.Model; +let Balance: mongoose.Model; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + + /** Register models */ + User = mongoose.models.User || mongoose.model('User', userSchema); + Balance = mongoose.models.Balance || mongoose.model('Balance', balanceSchema); + + /** Initialize methods */ + methods = createUserMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +describe('User Methods - Database Tests', () => { + describe('findUser', () => { + test('should find user by exact email', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + }); + + const found = await methods.findUser({ email: 'test@example.com' }); + + expect(found).toBeDefined(); + expect(found?.email).toBe('test@example.com'); + }); + + test('should find user by email with different case (case-insensitive)', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', // stored lowercase by schema + provider: 'local', + }); + + /** Test various case combinations - all should find the same user */ + const foundUpper = await methods.findUser({ email: 'TEST@EXAMPLE.COM' }); + const foundMixed = await methods.findUser({ email: 'Test@Example.COM' }); + const foundLower = await methods.findUser({ email: 'test@example.com' }); + + expect(foundUpper).toBeDefined(); + expect(foundUpper?.email).toBe('test@example.com'); + + expect(foundMixed).toBeDefined(); + expect(foundMixed?.email).toBe('test@example.com'); + + expect(foundLower).toBeDefined(); + expect(foundLower?.email).toBe('test@example.com'); + }); + + test('should find user by email with leading/trailing whitespace (trimmed)', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + }); + + const foundWithSpaces = await methods.findUser({ email: ' test@example.com ' }); + const foundWithTabs = await methods.findUser({ email: '\ttest@example.com\t' }); + + expect(foundWithSpaces).toBeDefined(); + expect(foundWithSpaces?.email).toBe('test@example.com'); + + expect(foundWithTabs).toBeDefined(); + expect(foundWithTabs?.email).toBe('test@example.com'); + }); + + test('should find user by email with both case difference and whitespace', async () => { + await User.create({ + name: 'Test User', + email: 'john.doe@example.com', + provider: 'local', + }); + + const found = await methods.findUser({ email: ' John.Doe@EXAMPLE.COM ' }); + + expect(found).toBeDefined(); + expect(found?.email).toBe('john.doe@example.com'); + }); + + test('should normalize email in $or conditions', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'openid', + openidId: 'openid-123', + }); + + const found = await methods.findUser({ + $or: [{ openidId: 'different-id' }, { email: 'TEST@EXAMPLE.COM' }], + }); + + expect(found).toBeDefined(); + expect(found?.email).toBe('test@example.com'); + }); + + test('should find user by non-email criteria without affecting them', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'openid', + openidId: 'openid-123', + }); + + const found = await methods.findUser({ openidId: 'openid-123' }); + + expect(found).toBeDefined(); + expect(found?.openidId).toBe('openid-123'); + }); + + test('should apply field selection correctly', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + username: 'testuser', + }); + + const found = await methods.findUser({ email: 'test@example.com' }, 'email name'); + + expect(found).toBeDefined(); + expect(found?.email).toBe('test@example.com'); + expect(found?.name).toBe('Test User'); + expect(found?.username).toBeUndefined(); + expect(found?.provider).toBeUndefined(); + }); + + test('should return null for non-existent user', async () => { + const found = await methods.findUser({ email: 'nonexistent@example.com' }); + + expect(found).toBeNull(); + }); + }); + + describe('createUser', () => { + test('should create a user and return ObjectId by default', async () => { + const result = await methods.createUser({ + name: 'New User', + email: 'new@example.com', + provider: 'local', + }); + + expect(result).toBeInstanceOf(mongoose.Types.ObjectId); + + const user = await User.findById(result); + expect(user).toBeDefined(); + expect(user?.name).toBe('New User'); + expect(user?.email).toBe('new@example.com'); + }); + + test('should create a user and return user object when returnUser is true', async () => { + const result = await methods.createUser( + { + name: 'New User', + email: 'new@example.com', + provider: 'local', + }, + undefined, + true, + true, + ); + + expect(result).toHaveProperty('_id'); + expect(result).toHaveProperty('name', 'New User'); + expect(result).toHaveProperty('email', 'new@example.com'); + }); + + test('should store email as lowercase regardless of input case', async () => { + await methods.createUser({ + name: 'New User', + email: 'NEW@EXAMPLE.COM', + provider: 'local', + }); + + const user = await User.findOne({ email: 'new@example.com' }); + expect(user).toBeDefined(); + expect(user?.email).toBe('new@example.com'); + }); + + test('should create user with TTL when disableTTL is false', async () => { + const result = await methods.createUser( + { + name: 'TTL User', + email: 'ttl@example.com', + provider: 'local', + }, + undefined, + false, + true, + ); + + expect(result).toHaveProperty('expiresAt'); + const expiresAt = (result as t.IUser).expiresAt; + expect(expiresAt).toBeInstanceOf(Date); + + /** Should expire in approximately 1 week */ + const oneWeekMs = 604800 * 1000; + const expectedExpiry = Date.now() + oneWeekMs; + expect(expiresAt!.getTime()).toBeGreaterThan(expectedExpiry - 10000); + expect(expiresAt!.getTime()).toBeLessThan(expectedExpiry + 10000); + }); + + test('should create balance record when balanceConfig is provided', async () => { + const userId = await methods.createUser( + { + name: 'Balance User', + email: 'balance@example.com', + provider: 'local', + }, + { + enabled: true, + startBalance: 1000, + }, + ); + + const balance = await Balance.findOne({ user: userId }); + expect(balance).toBeDefined(); + expect(balance?.tokenCredits).toBe(1000); + }); + }); + + describe('updateUser', () => { + test('should update user fields', async () => { + const user = await User.create({ + name: 'Original Name', + email: 'test@example.com', + provider: 'local', + }); + + const updated = await methods.updateUser(user._id?.toString() ?? '', { + name: 'Updated Name', + }); + + expect(updated).toBeDefined(); + expect(updated?.name).toBe('Updated Name'); + expect(updated?.email).toBe('test@example.com'); + }); + + test('should remove expiresAt field on update', async () => { + const user = await User.create({ + name: 'TTL User', + email: 'ttl@example.com', + provider: 'local', + expiresAt: new Date(Date.now() + 604800 * 1000), + }); + + const updated = await methods.updateUser(user._id?.toString() || '', { + name: 'No longer TTL', + }); + + expect(updated).toBeDefined(); + expect(updated?.expiresAt).toBeUndefined(); + }); + + test('should return null for non-existent user', async () => { + const fakeId = new mongoose.Types.ObjectId(); + const result = await methods.updateUser(fakeId.toString(), { name: 'Test' }); + + expect(result).toBeNull(); + }); + }); + + describe('getUserById', () => { + test('should get user by ID', async () => { + const user = await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + }); + + const found = await methods.getUserById(user._id?.toString() || ''); + + expect(found).toBeDefined(); + expect(found?.name).toBe('Test User'); + }); + + test('should apply field selection', async () => { + const user = await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + username: 'testuser', + }); + + const found = await methods.getUserById(user._id?.toString() || '', 'name email'); + + expect(found).toBeDefined(); + expect(found?.name).toBe('Test User'); + expect(found?.email).toBe('test@example.com'); + expect(found?.username).toBeUndefined(); + }); + + test('should return null for non-existent ID', async () => { + const fakeId = new mongoose.Types.ObjectId(); + const found = await methods.getUserById(fakeId.toString()); + + expect(found).toBeNull(); + }); + }); + + describe('deleteUserById', () => { + test('should delete user by ID', async () => { + const user = await User.create({ + name: 'To Delete', + email: 'delete@example.com', + provider: 'local', + }); + + const result = await methods.deleteUserById(user._id?.toString() || ''); + + expect(result.deletedCount).toBe(1); + expect(result.message).toBe('User was deleted successfully.'); + + const found = await User.findById(user._id); + expect(found).toBeNull(); + }); + + test('should return zero count for non-existent user', async () => { + const fakeId = new mongoose.Types.ObjectId(); + const result = await methods.deleteUserById(fakeId.toString()); + + expect(result.deletedCount).toBe(0); + expect(result.message).toBe('No user found with that ID.'); + }); + }); + + describe('countUsers', () => { + test('should count all users', async () => { + await User.create([ + { name: 'User 1', email: 'user1@example.com', provider: 'local' }, + { name: 'User 2', email: 'user2@example.com', provider: 'local' }, + { name: 'User 3', email: 'user3@example.com', provider: 'openid' }, + ]); + + const count = await methods.countUsers(); + + expect(count).toBe(3); + }); + + test('should count users with filter', async () => { + await User.create([ + { name: 'User 1', email: 'user1@example.com', provider: 'local' }, + { name: 'User 2', email: 'user2@example.com', provider: 'local' }, + { name: 'User 3', email: 'user3@example.com', provider: 'openid' }, + ]); + + const count = await methods.countUsers({ provider: 'local' }); + + expect(count).toBe(2); + }); + + test('should return zero for empty collection', async () => { + const count = await methods.countUsers(); + + expect(count).toBe(0); + }); + }); + + describe('searchUsers', () => { + beforeEach(async () => { + await User.create([ + { name: 'John Doe', email: 'john@example.com', username: 'johnd', provider: 'local' }, + { name: 'Jane Smith', email: 'jane@example.com', username: 'janes', provider: 'local' }, + { + name: 'Bob Johnson', + email: 'bob@example.com', + username: 'bobbyj', + provider: 'local', + }, + { + name: 'Alice Wonder', + email: 'alice@test.com', + username: 'alice', + provider: 'openid', + }, + ]); + }); + + test('should search by name', async () => { + const results = await methods.searchUsers({ searchPattern: 'John' }); + + expect(results).toHaveLength(2); // John Doe and Bob Johnson + }); + + test('should search by email', async () => { + const results = await methods.searchUsers({ searchPattern: 'example.com' }); + + expect(results).toHaveLength(3); + }); + + test('should search by username', async () => { + const results = await methods.searchUsers({ searchPattern: 'alice' }); + + expect(results).toHaveLength(1); + expect((results[0] as unknown as t.IUser)?.username).toBe('alice'); + }); + + test('should be case-insensitive', async () => { + const results = await methods.searchUsers({ searchPattern: 'JOHN' }); + + expect(results.length).toBeGreaterThan(0); + }); + + test('should respect limit', async () => { + const results = await methods.searchUsers({ searchPattern: 'example', limit: 2 }); + + expect(results).toHaveLength(2); + }); + + test('should return empty array for empty search pattern', async () => { + const results = await methods.searchUsers({ searchPattern: '' }); + + expect(results).toEqual([]); + }); + + test('should return empty array for whitespace-only pattern', async () => { + const results = await methods.searchUsers({ searchPattern: ' ' }); + + expect(results).toEqual([]); + }); + + test('should apply field selection', async () => { + const results = await methods.searchUsers({ + searchPattern: 'john', + fieldsToSelect: 'name email', + }); + + expect(results.length).toBeGreaterThan(0); + expect(results[0]).toHaveProperty('name'); + expect(results[0]).toHaveProperty('email'); + expect(results[0]).not.toHaveProperty('username'); + }); + + test('should sort by relevance (exact match first)', async () => { + const results = await methods.searchUsers({ searchPattern: 'alice' }); + + /** 'alice' username should score highest due to exact match */ + expect((results[0] as unknown as t.IUser).username).toBe('alice'); + }); + }); + + describe('toggleUserMemories', () => { + test('should enable memories for user', async () => { + const user = await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + }); + + const updated = await methods.toggleUserMemories(user._id?.toString() || '', true); + + expect(updated).toBeDefined(); + expect(updated?.personalization?.memories).toBe(true); + }); + + test('should disable memories for user', async () => { + const user = await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + personalization: { memories: true }, + }); + + const updated = await methods.toggleUserMemories(user._id?.toString() || '', false); + + expect(updated).toBeDefined(); + expect(updated?.personalization?.memories).toBe(false); + }); + + test('should update personalization.memories field', async () => { + const user = await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + }); + + /** Toggle memories to true */ + const updated = await methods.toggleUserMemories(user._id?.toString() || '', true); + + expect(updated?.personalization).toBeDefined(); + expect(updated?.personalization?.memories).toBe(true); + + /** Toggle back to false */ + const updatedAgain = await methods.toggleUserMemories(user._id?.toString() || '', false); + expect(updatedAgain?.personalization?.memories).toBe(false); + }); + + test('should return null for non-existent user', async () => { + const fakeId = new mongoose.Types.ObjectId(); + const result = await methods.toggleUserMemories(fakeId.toString(), true); + + expect(result).toBeNull(); + }); + }); + + describe('Email Normalization Edge Cases', () => { + test('should handle email with multiple spaces', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + }); + + const found = await methods.findUser({ email: ' test@example.com ' }); + + expect(found).toBeDefined(); + expect(found?.email).toBe('test@example.com'); + }); + + test('should handle mixed case with international characters', async () => { + await User.create({ + name: 'Test User', + email: 'user@example.com', + provider: 'local', + }); + + const found = await methods.findUser({ email: 'USER@EXAMPLE.COM' }); + + expect(found).toBeDefined(); + }); + + test('should handle email normalization in complex $or queries', async () => { + const user1 = await User.create({ + name: 'User One', + email: 'user1@example.com', + provider: 'openid', + openidId: 'openid-1', + }); + + await User.create({ + name: 'User Two', + email: 'user2@example.com', + provider: 'openid', + openidId: 'openid-2', + }); + + /** Search with mixed case email in $or */ + const found = await methods.findUser({ + $or: [{ openidId: 'nonexistent' }, { email: 'USER1@EXAMPLE.COM' }], + }); + + expect(found).toBeDefined(); + expect(found?._id?.toString()).toBe(user1._id?.toString()); + }); + + test('should not normalize non-string email values', async () => { + await User.create({ + name: 'Test User', + email: 'test@example.com', + provider: 'local', + }); + + /** Using regex for email (should not be normalized) */ + const found = await methods.findUser({ email: /test@example\.com/i }); + + expect(found).toBeDefined(); + expect(found?.email).toBe('test@example.com'); + }); + + test('should handle OpenID provider migration scenario', async () => { + /** Simulate user stored with lowercase email */ + await User.create({ + name: 'John Doe', + email: 'john.doe@company.com', + provider: 'openid', + openidId: 'old-provider-id', + }); + + /** + * New OpenID provider returns email with different casing + * This simulates the exact bug reported in the GitHub issue + */ + const emailFromNewProvider = 'John.Doe@Company.COM'; + + const found = await methods.findUser({ email: emailFromNewProvider }); + + expect(found).toBeDefined(); + expect(found?.email).toBe('john.doe@company.com'); + expect(found?.name).toBe('John Doe'); + }); + + test('should handle SAML provider email normalization', async () => { + await User.create({ + name: 'SAML User', + email: 'saml.user@enterprise.com', + provider: 'saml', + samlId: 'saml-123', + }); + + /** SAML providers sometimes return emails in different formats */ + const found = await methods.findUser({ email: ' SAML.USER@ENTERPRISE.COM ' }); + + expect(found).toBeDefined(); + expect(found?.provider).toBe('saml'); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 83a433da73..26ba070288 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -4,15 +4,37 @@ import { signPayload } from '~/crypto'; /** Factory function that takes mongoose instance and returns the methods */ export function createUserMethods(mongoose: typeof import('mongoose')) { + /** + * Normalizes email fields in search criteria to lowercase and trimmed. + * Handles both direct email fields and $or arrays containing email conditions. + */ + function normalizeEmailInCriteria>(criteria: T): T { + const normalized = { ...criteria }; + if (typeof normalized.email === 'string') { + normalized.email = normalized.email.trim().toLowerCase(); + } + if (Array.isArray(normalized.$or)) { + normalized.$or = normalized.$or.map((condition) => { + if (typeof condition.email === 'string') { + return { ...condition, email: condition.email.trim().toLowerCase() }; + } + return condition; + }); + } + return normalized; + } + /** * Search for a single user based on partial data and return matching user document as plain object. + * Email fields in searchCriteria are automatically normalized to lowercase for case-insensitive matching. */ async function findUser( searchCriteria: FilterQuery, fieldsToSelect?: string | string[] | null, ): Promise { const User = mongoose.models.User; - const query = User.findOne(searchCriteria); + const normalizedCriteria = normalizeEmailInCriteria(searchCriteria); + const query = User.findOne(normalizedCriteria); if (fieldsToSelect) { query.select(fieldsToSelect); }