mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 08:12:00 +02:00

* implemented Apple Auth login. Closes: #3438 TODO: - write config Doc * removed some comments * removed comment * Add unit tests for Apple login strategy Introduce comprehensive tests for the Apple login strategy, covering new user creation, existing user updates, and error handling scenarios during the authentication flow. Mocks implemented for external dependencies to ensure isolated testing. * Remove unnecessary blank line in socialLogins.js
376 lines
12 KiB
JavaScript
376 lines
12 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
const jwt = require('jsonwebtoken');
|
|
const { Strategy: AppleStrategy } = require('passport-apple');
|
|
const socialLogin = require('./socialLogin');
|
|
const User = require('~/models/User');
|
|
const { logger } = require('~/config');
|
|
const { createSocialUser, handleExistingUser } = require('./process');
|
|
const { isEnabled } = require('~/server/utils');
|
|
const { findUser } = require('~/models');
|
|
|
|
// Mocking external dependencies
|
|
jest.mock('jsonwebtoken');
|
|
jest.mock('~/config', () => ({
|
|
logger: {
|
|
error: jest.fn(),
|
|
debug: jest.fn(),
|
|
},
|
|
}));
|
|
jest.mock('./process', () => ({
|
|
createSocialUser: jest.fn(),
|
|
handleExistingUser: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/utils', () => ({
|
|
isEnabled: jest.fn(),
|
|
}));
|
|
jest.mock('~/models', () => ({
|
|
findUser: jest.fn(),
|
|
}));
|
|
|
|
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 }) => {
|
|
console.log('getProfileDetails called with idToken:', idToken);
|
|
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.mockImplementation(({ email }) => User.findOne({ email }));
|
|
});
|
|
|
|
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
|
|
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',
|
|
});
|
|
await existingUser.save();
|
|
|
|
// Mock findUser to return the existing user
|
|
findUser.mockResolvedValue(existingUser);
|
|
|
|
// Mock handleExistingUser to update avatarUrl
|
|
handleExistingUser.mockImplementation(async (user, avatarUrl) => {
|
|
user.avatarUrl = avatarUrl;
|
|
await user.save();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|
|
});
|