mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
📬 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:
parent
d7ce19e15a
commit
d5d362e52b
7 changed files with 847 additions and 4 deletions
|
|
@ -292,7 +292,7 @@ const ensurePrincipalExists = async function (principal) {
|
||||||
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
|
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
|
||||||
|
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
existingUser = await findUser({ email: principal.email.toLowerCase() });
|
existingUser = await findUser({ email: principal.email });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ describe('socialLogin', () => {
|
||||||
|
|
||||||
/** Verify both searches happened */
|
/** Verify both searches happened */
|
||||||
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
||||||
|
/** Email passed as-is; findUser implementation handles case normalization */
|
||||||
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
|
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
|
||||||
expect(findUser).toHaveBeenCalledTimes(2);
|
expect(findUser).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,34 @@ describe('findOpenIDUser', () => {
|
||||||
expect(mockFindUser).toHaveBeenCalledWith({ email: 'user@example.com' });
|
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 () => {
|
it('should handle findUser throwing an error', async () => {
|
||||||
mockFindUser.mockRejectedValueOnce(new Error('Database error'));
|
mockFindUser.mockRejectedValueOnce(new Error('Database error'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -617,4 +617,171 @@ describe('Token Methods - Detailed Tests', () => {
|
||||||
expect(remainingTokens.find((t) => t.token === 'email-verify-token-2')).toBeUndefined();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes all Token documents that match the provided token, user ID, or email.
|
* 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> {
|
async function deleteTokens(query: TokenQuery): Promise<TokenDeleteResult> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,7 +58,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
|
||||||
conditions.push({ token: query.token });
|
conditions.push({ token: query.token });
|
||||||
}
|
}
|
||||||
if (query.email !== undefined) {
|
if (query.email !== undefined) {
|
||||||
conditions.push({ email: query.email });
|
conditions.push({ email: query.email.trim().toLowerCase() });
|
||||||
}
|
}
|
||||||
if (query.identifier !== undefined) {
|
if (query.identifier !== undefined) {
|
||||||
conditions.push({ identifier: query.identifier });
|
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.
|
* 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> {
|
async function findToken(query: TokenQuery, options?: QueryOptions): Promise<IToken | null> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -94,7 +96,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
|
||||||
conditions.push({ token: query.token });
|
conditions.push({ token: query.token });
|
||||||
}
|
}
|
||||||
if (query.email) {
|
if (query.email) {
|
||||||
conditions.push({ email: query.email });
|
conditions.push({ email: query.email.trim().toLowerCase() });
|
||||||
}
|
}
|
||||||
if (query.identifier) {
|
if (query.identifier) {
|
||||||
conditions.push({ identifier: query.identifier });
|
conditions.push({ identifier: query.identifier });
|
||||||
|
|
|
||||||
623
packages/data-schemas/src/methods/user.methods.spec.ts
Normal file
623
packages/data-schemas/src/methods/user.methods.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,15 +4,37 @@ import { signPayload } from '~/crypto';
|
||||||
|
|
||||||
/** Factory function that takes mongoose instance and returns the methods */
|
/** Factory function that takes mongoose instance and returns the methods */
|
||||||
export function createUserMethods(mongoose: typeof import('mongoose')) {
|
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.
|
* 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(
|
async function findUser(
|
||||||
searchCriteria: FilterQuery<IUser>,
|
searchCriteria: FilterQuery<IUser>,
|
||||||
fieldsToSelect?: string | string[] | null,
|
fieldsToSelect?: string | string[] | null,
|
||||||
): Promise<IUser | null> {
|
): Promise<IUser | null> {
|
||||||
const User = mongoose.models.User;
|
const User = mongoose.models.User;
|
||||||
const query = User.findOne(searchCriteria);
|
const normalizedCriteria = normalizeEmailInCriteria(searchCriteria);
|
||||||
|
const query = User.findOne(normalizedCriteria);
|
||||||
if (fieldsToSelect) {
|
if (fieldsToSelect) {
|
||||||
query.select(fieldsToSelect);
|
query.select(fieldsToSelect);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue