LibreChat/api/strategies/socialLogin.test.js
Danny Avila d5d362e52b
📬 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.
2025-12-01 09:41:25 -05:00

277 lines
8.6 KiB
JavaScript

const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { createSocialUser, handleExistingUser } = require('./process');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
return {
...actualModule,
logger: {
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
};
});
jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn().mockReturnValue(true),
isEmailDomainAllowed: jest.fn().mockReturnValue(true),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
fileStrategy: 'local',
balance: { enabled: false },
}),
}));
describe('socialLogin', () => {
const mockGetProfileDetails = ({ profile }) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos?.[0]?.value || null,
username: profile.name?.givenName || 'user',
name: `${profile.name?.givenName || ''} ${profile.name?.familyName || ''}`.trim(),
emailVerified: profile.emails[0].verified || false,
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('Finding users by provider ID', () => {
it('should find user by provider ID (googleId) when email has changed', async () => {
const provider = 'google';
const googleId = 'google-user-123';
const oldEmail = 'old@example.com';
const newEmail = 'new@example.com';
const existingUser = {
_id: 'user123',
email: oldEmail,
provider: 'google',
googleId: googleId,
};
/** Mock findUser to return user on first call (by googleId), null on second call */
findUser
.mockResolvedValueOnce(existingUser) // First call: finds by googleId
.mockResolvedValueOnce(null); // Second call would be by email, but won't be reached
const mockProfile = {
id: googleId,
emails: [{ value: newEmail, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'John', familyName: 'Doe' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by googleId first */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
/** Verify it did NOT search by email (because it found user by googleId) */
expect(findUser).toHaveBeenCalledTimes(1);
/** Verify handleExistingUser was called with the new email */
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
'https://example.com/avatar.png',
expect.any(Object),
newEmail,
);
/** Verify callback was called with success */
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should find user by provider ID (facebookId) when using Facebook', async () => {
const provider = 'facebook';
const facebookId = 'fb-user-456';
const email = 'user@example.com';
const existingUser = {
_id: 'user456',
email: email,
provider: 'facebook',
facebookId: facebookId,
};
findUser.mockResolvedValue(existingUser); // Always returns user
const mockProfile = {
id: facebookId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/fb-avatar.png' }],
name: { givenName: 'Jane', familyName: 'Smith' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by facebookId first */
expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId });
expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]);
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
'https://example.com/fb-avatar.png',
expect.any(Object),
email,
);
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should fallback to finding user by email if not found by provider ID', async () => {
const provider = 'google';
const googleId = 'google-user-789';
const email = 'user@example.com';
const existingUser = {
_id: 'user789',
email: email,
provider: 'google',
googleId: 'old-google-id', // Different googleId (edge case)
};
/** First call (by googleId) returns null, second call (by email) returns user */
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'Bob', familyName: 'Johnson' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** 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);
/** Verify warning log */
expect(logger.warn).toHaveBeenCalledWith(
`[${provider}Login] User found by email: ${email} but not by ${provider}Id`,
);
expect(handleExistingUser).toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should create new user if not found by provider ID or email', async () => {
const provider = 'google';
const googleId = 'google-new-user';
const email = 'newuser@example.com';
const newUser = {
_id: 'newuser123',
email: email,
provider: 'google',
googleId: googleId,
};
/** Both searches return null */
findUser.mockResolvedValue(null);
createSocialUser.mockResolvedValue(newUser);
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'New', familyName: 'User' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify both searches happened */
expect(findUser).toHaveBeenCalledTimes(2);
/** Verify createSocialUser was called */
expect(createSocialUser).toHaveBeenCalledWith({
email: email,
avatarUrl: 'https://example.com/avatar.png',
provider: provider,
providerKey: 'googleId',
providerId: googleId,
username: 'New',
name: 'New User',
emailVerified: true,
appConfig: expect.any(Object),
});
expect(callback).toHaveBeenCalledWith(null, newUser);
});
});
describe('Error handling', () => {
it('should return error if user exists with different provider', async () => {
const provider = 'google';
const googleId = 'google-user-123';
const email = 'user@example.com';
const existingUser = {
_id: 'user123',
email: email,
provider: 'local', // Different provider
};
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'John', familyName: 'Doe' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify error callback */
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
code: ErrorTypes.AUTH_FAILED,
provider: 'local',
}),
);
expect(logger.info).toHaveBeenCalledWith(
`[${provider}Login] User ${email} already exists with provider local`,
);
});
});
});