| const jwt = require('jsonwebtoken'); |
| const mongoose = require('mongoose'); |
| const { isEnabled } = require('@librechat/api'); |
| const { logger } = require('@librechat/data-schemas'); |
| const { Strategy: AppleStrategy } = require('passport-apple'); |
| const { MongoMemoryServer } = require('mongodb-memory-server'); |
| const { createSocialUser, handleExistingUser } = require('./process'); |
| const socialLogin = require('./socialLogin'); |
| const { findUser } = require('~/models'); |
| const { User } = require('~/db/models'); |
|
|
| jest.mock('jsonwebtoken'); |
| jest.mock('@librechat/data-schemas', () => { |
| const actualModule = jest.requireActual('@librechat/data-schemas'); |
| return { |
| ...actualModule, |
| logger: { |
| error: jest.fn(), |
| debug: 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(), |
| })); |
| jest.mock('~/models', () => ({ |
| findUser: jest.fn(), |
| })); |
| jest.mock('~/server/services/Config', () => ({ |
| getAppConfig: jest.fn().mockResolvedValue({ |
| fileStrategy: 'local', |
| balance: { enabled: false }, |
| }), |
| })); |
|
|
| describe('Apple Login Strategy', () => { |
| let mongoServer; |
| let appleStrategyInstance; |
| const OLD_ENV = process.env; |
| let getProfileDetails; |
|
|
| |
| beforeAll(async () => { |
| mongoServer = await MongoMemoryServer.create(); |
| const mongoUri = mongoServer.getUri(); |
| await mongoose.connect(mongoUri); |
| }); |
|
|
| afterAll(async () => { |
| await mongoose.disconnect(); |
| await mongoServer.stop(); |
| process.env = OLD_ENV; |
| }); |
|
|
| beforeEach(async () => { |
| |
| process.env = { ...OLD_ENV }; |
| process.env.APPLE_CLIENT_ID = 'fake_client_id'; |
| process.env.APPLE_TEAM_ID = 'fake_team_id'; |
| process.env.APPLE_CALLBACK_URL = '/auth/apple/callback'; |
| process.env.DOMAIN_SERVER = 'https://example.com'; |
| process.env.APPLE_KEY_ID = 'fake_key_id'; |
| process.env.APPLE_PRIVATE_KEY_PATH = '/path/to/fake/private/key'; |
| process.env.ALLOW_SOCIAL_REGISTRATION = 'true'; |
|
|
| |
| jest.clearAllMocks(); |
| await User.deleteMany({}); |
|
|
| |
| getProfileDetails = ({ idToken, profile }) => { |
| if (!idToken) { |
| logger.error('idToken is missing'); |
| throw new Error('idToken is missing'); |
| } |
|
|
| const decoded = jwt.decode(idToken); |
| if (!decoded) { |
| logger.error('Failed to decode idToken'); |
| throw new Error('idToken is invalid'); |
| } |
|
|
| console.log('Decoded token:', decoded); |
|
|
| logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`); |
|
|
| return { |
| email: decoded.email, |
| id: decoded.sub, |
| avatarUrl: null, |
| username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`, |
| name: decoded.name |
| ? `${decoded.name.firstName} ${decoded.name.lastName}` |
| : profile.displayName || null, |
| emailVerified: true, |
| }; |
| }; |
|
|
| |
| isEnabled.mockImplementation((flag) => { |
| if (flag === 'true') { |
| return true; |
| } |
| if (flag === 'false') { |
| return false; |
| } |
| return false; |
| }); |
|
|
| |
| const appleLogin = socialLogin('apple', getProfileDetails); |
| appleStrategyInstance = new AppleStrategy( |
| { |
| clientID: process.env.APPLE_CLIENT_ID, |
| teamID: process.env.APPLE_TEAM_ID, |
| callbackURL: `${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`, |
| keyID: process.env.APPLE_KEY_ID, |
| privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH, |
| passReqToCallback: false, |
| }, |
| appleLogin, |
| ); |
| }); |
|
|
| const mockProfile = { |
| displayName: 'John Doe', |
| }; |
|
|
| describe('getProfileDetails', () => { |
| it('should throw an error if idToken is missing', () => { |
| expect(() => { |
| getProfileDetails({ idToken: null, profile: mockProfile }); |
| }).toThrow('idToken is missing'); |
| expect(logger.error).toHaveBeenCalledWith('idToken is missing'); |
| }); |
|
|
| it('should throw an error if idToken cannot be decoded', () => { |
| jwt.decode.mockReturnValue(null); |
| expect(() => { |
| getProfileDetails({ idToken: 'invalid_id_token', profile: mockProfile }); |
| }).toThrow('idToken is invalid'); |
| expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken'); |
| }); |
|
|
| it('should extract user details correctly from idToken', () => { |
| const fakeDecodedToken = { |
| email: 'john.doe@example.com', |
| sub: 'apple-sub-1234', |
| name: { |
| firstName: 'John', |
| lastName: 'Doe', |
| }, |
| }; |
|
|
| jwt.decode.mockReturnValue(fakeDecodedToken); |
|
|
| const profileDetails = getProfileDetails({ |
| idToken: 'fake_id_token', |
| profile: mockProfile, |
| }); |
|
|
| expect(jwt.decode).toHaveBeenCalledWith('fake_id_token'); |
| expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Decoded Apple JWT')); |
| expect(profileDetails).toEqual({ |
| email: 'john.doe@example.com', |
| id: 'apple-sub-1234', |
| avatarUrl: null, |
| username: 'john.doe', |
| name: 'John Doe', |
| emailVerified: true, |
| }); |
| }); |
|
|
| it('should handle missing email and use sub for username', () => { |
| const fakeDecodedToken = { |
| sub: 'apple-sub-5678', |
| }; |
|
|
| jwt.decode.mockReturnValue(fakeDecodedToken); |
|
|
| const profileDetails = getProfileDetails({ |
| idToken: 'fake_id_token', |
| profile: mockProfile, |
| }); |
|
|
| expect(profileDetails).toEqual({ |
| email: undefined, |
| id: 'apple-sub-5678', |
| avatarUrl: null, |
| username: 'user_apple-sub-5678', |
| name: 'John Doe', |
| emailVerified: true, |
| }); |
| }); |
| }); |
|
|
| describe('Strategy verify callback', () => { |
| const tokenset = { |
| id_token: 'fake_id_token', |
| }; |
|
|
| const decodedToken = { |
| email: 'jane.doe@example.com', |
| sub: 'apple-sub-9012', |
| name: { |
| firstName: 'Jane', |
| lastName: 'Doe', |
| }, |
| }; |
|
|
| const fakeAccessToken = 'fake_access_token'; |
| const fakeRefreshToken = 'fake_refresh_token'; |
|
|
| beforeEach(() => { |
| jwt.decode.mockReturnValue(decodedToken); |
| findUser.mockResolvedValue(null); |
| }); |
|
|
| it('should create a new user if one does not exist and registration is allowed', async () => { |
| |
| findUser.mockResolvedValue(null); |
|
|
| |
| createSocialUser.mockImplementation(async (userData) => { |
| const user = new User(userData); |
| await user.save(); |
| return user; |
| }); |
|
|
| const mockVerifyCallback = jest.fn(); |
|
|
| |
| await new Promise((resolve) => { |
| appleStrategyInstance._verify( |
| fakeAccessToken, |
| fakeRefreshToken, |
| tokenset.id_token, |
| mockProfile, |
| (err, user) => { |
| mockVerifyCallback(err, user); |
| resolve(); |
| }, |
| ); |
| }); |
|
|
| expect(mockVerifyCallback).toHaveBeenCalledWith(null, expect.any(User)); |
| const user = mockVerifyCallback.mock.calls[0][1]; |
| expect(user.email).toBe('jane.doe@example.com'); |
| expect(user.username).toBe('jane.doe'); |
| expect(user.name).toBe('Jane Doe'); |
| expect(user.provider).toBe('apple'); |
| }); |
|
|
| it('should handle existing user and update avatarUrl', async () => { |
| |
| const existingUser = new User({ |
| email: 'jane.doe@example.com', |
| username: 'jane.doe', |
| name: 'Jane Doe', |
| provider: 'apple', |
| providerId: 'apple-sub-9012', |
| avatarUrl: 'old_avatar.png', |
| }); |
|
|
| |
| findUser.mockResolvedValue(existingUser); |
|
|
| |
| handleExistingUser.mockImplementation(async (user, avatarUrl) => { |
| user.avatarUrl = avatarUrl; |
| |
| return user; |
| }); |
|
|
| const mockVerifyCallback = jest.fn(); |
|
|
| |
| await new Promise((resolve) => { |
| appleStrategyInstance._verify( |
| fakeAccessToken, |
| fakeRefreshToken, |
| tokenset.id_token, |
| mockProfile, |
| (err, user) => { |
| mockVerifyCallback(err, user); |
| resolve(); |
| }, |
| ); |
| }); |
|
|
| expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser); |
| expect(existingUser.avatarUrl).toBeNull(); |
| expect(handleExistingUser).toHaveBeenCalledWith( |
| existingUser, |
| null, |
| expect.objectContaining({ |
| fileStrategy: 'local', |
| balance: { enabled: false }, |
| }), |
| 'jane.doe@example.com', |
| ); |
| }); |
|
|
| it('should handle missing idToken gracefully', async () => { |
| const mockVerifyCallback = jest.fn(); |
|
|
| |
| await new Promise((resolve) => { |
| appleStrategyInstance._verify( |
| fakeAccessToken, |
| fakeRefreshToken, |
| null, |
| mockProfile, |
| (err, user) => { |
| mockVerifyCallback(err, user); |
| resolve(); |
| }, |
| ); |
| }); |
|
|
| expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined); |
| expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is missing'); |
| |
| expect(createSocialUser).not.toHaveBeenCalled(); |
| expect(handleExistingUser).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should handle decoding errors gracefully', async () => { |
| |
| jwt.decode.mockReturnValue(null); |
|
|
| const mockVerifyCallback = jest.fn(); |
|
|
| |
| await new Promise((resolve) => { |
| appleStrategyInstance._verify( |
| fakeAccessToken, |
| fakeRefreshToken, |
| tokenset.id_token, |
| mockProfile, |
| (err, user) => { |
| mockVerifyCallback(err, user); |
| resolve(); |
| }, |
| ); |
| }); |
|
|
| expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined); |
| expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is invalid'); |
| |
| expect(createSocialUser).not.toHaveBeenCalled(); |
| expect(handleExistingUser).not.toHaveBeenCalled(); |
| |
| expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken'); |
| }); |
|
|
| it('should handle errors during user creation', async () => { |
| |
| findUser.mockResolvedValue(null); |
|
|
| |
| createSocialUser.mockImplementation(() => { |
| throw new Error('Database error'); |
| }); |
|
|
| const mockVerifyCallback = jest.fn(); |
|
|
| |
| await new Promise((resolve) => { |
| appleStrategyInstance._verify( |
| fakeAccessToken, |
| fakeRefreshToken, |
| tokenset.id_token, |
| mockProfile, |
| (err, user) => { |
| mockVerifyCallback(err, user); |
| resolve(); |
| }, |
| ); |
| }); |
|
|
| expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined); |
| expect(mockVerifyCallback.mock.calls[0][0].message).toBe('Database error'); |
| |
| expect(logger.error).toHaveBeenCalledWith('[appleLogin]', expect.any(Error)); |
| }); |
| }); |
| }); |
|
|