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; // Start and stop in-memory MongoDB 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 () => { // Reset environment variables 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'; // Clear mocks and database jest.clearAllMocks(); await User.deleteMany({}); // Define getProfileDetails within the test scope 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, // Apple does not provide an avatar URL 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, // Apple verifies the email }; }; // Mock isEnabled based on environment variable isEnabled.mockImplementation((flag) => { if (flag === 'true') { return true; } if (flag === 'false') { return false; } return false; }); // Initialize the strategy with the mocked getProfileDetails 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 () => { // Mock findUser to return null (user does not exist) findUser.mockResolvedValue(null); // Mock createSocialUser to create a user createSocialUser.mockImplementation(async (userData) => { const user = new User(userData); await user.save(); return user; }); const mockVerifyCallback = jest.fn(); // Invoke the verify callback with correct arguments 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 () => { // Create an existing user without saving to database 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', }); // Mock findUser to return the existing user findUser.mockResolvedValue(existingUser); // Mock handleExistingUser to update avatarUrl without saving to database handleExistingUser.mockImplementation(async (user, avatarUrl) => { user.avatarUrl = avatarUrl; // Don't call save() to avoid database operations return user; }); const mockVerifyCallback = jest.fn(); // Invoke the verify callback with correct arguments 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(); // As per getProfileDetails expect(handleExistingUser).toHaveBeenCalledWith( existingUser, null, expect.objectContaining({ fileStrategy: 'local', balance: { enabled: false }, }), ); }); it('should handle missing idToken gracefully', async () => { const mockVerifyCallback = jest.fn(); // Invoke the verify callback with missing id_token await new Promise((resolve) => { appleStrategyInstance._verify( fakeAccessToken, fakeRefreshToken, null, // idToken is missing 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'); // Ensure createSocialUser and handleExistingUser were not called expect(createSocialUser).not.toHaveBeenCalled(); expect(handleExistingUser).not.toHaveBeenCalled(); }); it('should handle decoding errors gracefully', async () => { // Simulate decoding failure by returning null jwt.decode.mockReturnValue(null); const mockVerifyCallback = jest.fn(); // Invoke the verify callback with correct arguments 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'); // Ensure createSocialUser and handleExistingUser were not called expect(createSocialUser).not.toHaveBeenCalled(); expect(handleExistingUser).not.toHaveBeenCalled(); // Ensure logger.error was called expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken'); }); it('should handle errors during user creation', async () => { // Mock findUser to return null (user does not exist) findUser.mockResolvedValue(null); // Mock createSocialUser to throw an error createSocialUser.mockImplementation(() => { throw new Error('Database error'); }); const mockVerifyCallback = jest.fn(); // Invoke the verify callback with correct arguments 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'); // Ensure logger.error was called expect(logger.error).toHaveBeenCalledWith('[appleLogin]', expect.any(Error)); }); }); });