📬 refactor: Normalize Email Handling in User Methods (#10743)

- 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.
This commit is contained in:
Danny Avila 2025-12-01 09:41:25 -05:00 committed by GitHub
parent d7ce19e15a
commit d5d362e52b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 847 additions and 4 deletions

View file

@ -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) {

View file

@ -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);

View file

@ -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'));

View file

@ -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);
});
});
});
});

View file

@ -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<TokenDeleteResult> {
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<IToken | null> {
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 });

View file

@ -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<t.IUser>;
let Balance: mongoose.Model<t.IBalance>;
let methods: ReturnType<typeof createUserMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
/** Register models */
User = mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
Balance = mongoose.models.Balance || mongoose.model<t.IBalance>('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');
});
});
});

View file

@ -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<T extends FilterQuery<IUser>>(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<IUser>,
fieldsToSelect?: string | string[] | null,
): Promise<IUser | null> {
const User = mongoose.models.User;
const query = User.findOne(searchCriteria);
const normalizedCriteria = normalizeEmailInCriteria(searchCriteria);
const query = User.findOne(normalizedCriteria);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}