mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
* refactor: Add existingUsersOnly support to social and SAML auth callbacks
- Add `existingUsersOnly` option to the `socialLogin` handler factory to
reject unknown users instead of creating new accounts
- Refactor SAML strategy callback into `createSamlCallback(existingUsersOnly)`
factory function, mirroring the OpenID `createOpenIDCallback` pattern
- Extract shared SAML config into `getBaseSamlConfig()` helper
- Register `samlAdmin` passport strategy with `existingUsersOnly: true` and
admin-specific callback URL, called automatically from `setupSaml()`
* feat: Register admin OAuth strategy variants for all social providers
- Add admin strategy exports to Google, GitHub, Discord, Facebook, and
Apple strategy files with admin callback URLs and existingUsersOnly
- Extract provider configs into reusable helpers to avoid duplication
between regular and admin strategy constructors
- Re-export all admin strategy factories from strategies/index.js
- Register admin passport strategies (googleAdmin, githubAdmin, etc.)
alongside regular ones in socialLogins.js when env vars are present
* feat: Add admin auth routes for SAML and social OAuth providers
- Add initiation and callback routes for SAML, Google, GitHub, Discord,
Facebook, and Apple to the admin auth router
- Each provider follows the exchange code + PKCE pattern established by
OpenID admin auth: store PKCE challenge on initiation, retrieve on
callback, generate exchange code for the admin panel
- SAML and Apple use POST callbacks with state extracted from
req.body.RelayState and req.body.state respectively
- Extract storePkceChallenge(), retrievePkceChallenge(), and
generateState() helpers; refactor existing OpenID routes to use them
- All callback chains enforce requireAdminAccess, setBalanceConfig,
checkDomainAllowed, and the shared createOAuthHandler
- No changes needed to the generic POST /oauth/exchange endpoint
* fix: Update SAML strategy test to handle dual strategy registration
setupSaml() now registers both 'saml' and 'samlAdmin' strategies,
causing the SamlStrategy mock to be called twice. The verifyCallback
variable was getting overwritten with the admin callback (which has
existingUsersOnly: true), making all new-user tests fail.
Fix: capture only the first callback per setupSaml() call and reset
between tests.
* fix: Address review findings for admin OAuth strategy changes
- Fix existingUsersOnly rejection in socialLogin.js to use
cb(null, false, { message }) instead of cb(error), ensuring
passport's failureRedirect fires correctly for admin flows
- Consolidate duplicate require() calls in strategies/index.js by
destructuring admin exports from the already-imported default export
- Pass pre-parsed baseConfig to setupSamlAdmin() to avoid redundant
certificate file I/O at startup
- Extract getGoogleConfig() helper in googleStrategy.js for consistency
with all other provider strategy files
- Replace randomState() (openid-client) with generateState() (crypto)
in the OpenID admin route for consistency with all other providers,
and remove the now-unused openid-client import
* Reorder import statements in auth.js
499 lines
17 KiB
JavaScript
499 lines
17 KiB
JavaScript
// --- Mocks ---
|
|
jest.mock('fs');
|
|
jest.mock('path');
|
|
jest.mock('node-fetch');
|
|
jest.mock('@node-saml/passport-saml');
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
info: jest.fn(),
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
},
|
|
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
|
}));
|
|
jest.mock('~/models', () => ({
|
|
findUser: jest.fn(),
|
|
createUser: jest.fn(),
|
|
updateUser: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Config', () => ({
|
|
config: {
|
|
registration: {
|
|
socialLogins: ['saml'],
|
|
},
|
|
},
|
|
getAppConfig: jest.fn().mockResolvedValue({}),
|
|
}));
|
|
jest.mock('@librechat/api', () => ({
|
|
isEmailDomainAllowed: jest.fn(() => true),
|
|
getBalanceConfig: jest.fn(() => ({
|
|
tokenCredits: 1000,
|
|
startBalance: 1000,
|
|
})),
|
|
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
|
|
}));
|
|
jest.mock('~/server/services/Config/EndpointService', () => ({
|
|
config: {},
|
|
}));
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
|
getStrategyFunctions: jest.fn(() => ({
|
|
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
|
})),
|
|
}));
|
|
jest.mock('~/config/paths', () => ({
|
|
root: '/fake/root/path',
|
|
}));
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const fetch = require('node-fetch');
|
|
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
|
const { findUser } = require('~/models');
|
|
const { resolveAppConfigForUser } = require('@librechat/api');
|
|
const { getAppConfig } = require('~/server/services/Config');
|
|
const { setupSaml, getCertificateContent } = require('./samlStrategy');
|
|
|
|
// Configure fs mock
|
|
jest.mocked(fs).existsSync = jest.fn();
|
|
jest.mocked(fs).statSync = jest.fn();
|
|
jest.mocked(fs).readFileSync = jest.fn();
|
|
|
|
// To capture the verify callback from the strategy, we grab it from the mock constructor.
|
|
// setupSaml() registers both 'saml' (regular) and 'samlAdmin' strategies, so we capture
|
|
// only the first callback per setupSaml() call (the regular one).
|
|
let verifyCallback;
|
|
SamlStrategy.mockImplementation((options, verify) => {
|
|
if (!verifyCallback) {
|
|
verifyCallback = verify;
|
|
}
|
|
return { name: 'saml', options, verify };
|
|
});
|
|
|
|
describe('getCertificateContent', () => {
|
|
const certWithHeader = `-----BEGIN CERTIFICATE-----
|
|
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
|
|
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
|
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
|
|
MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
|
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
|
AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
|
|
O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
|
|
VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
|
|
GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
|
|
4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
|
|
6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
|
|
DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
|
|
IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
|
|
Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
|
|
FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
|
|
Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
|
|
Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
|
|
meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
|
|
u7wlOSk+oFzDIO/UILIA
|
|
-----END CERTIFICATE-----`;
|
|
|
|
const certWithoutHeader = certWithHeader
|
|
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
|
.replace(/-----END CERTIFICATE-----/g, '')
|
|
.replace(/\s+/g, '');
|
|
|
|
it('should throw an error if SAML_CERT is not set', () => {
|
|
process.env.SAML_CERT;
|
|
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
|
|
'Invalid input: SAML_CERT must be a string.',
|
|
);
|
|
});
|
|
|
|
it('should throw an error if SAML_CERT is empty', () => {
|
|
process.env.SAML_CERT = '';
|
|
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
|
|
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
|
|
);
|
|
});
|
|
|
|
it('should load cert from an environment variable if it is a single-line string(with header)', () => {
|
|
process.env.SAML_CERT = certWithHeader;
|
|
|
|
const actual = getCertificateContent(process.env.SAML_CERT);
|
|
expect(actual).toBe(certWithHeader);
|
|
});
|
|
|
|
it('should load cert from an environment variable if it is a single-line string(with no header)', () => {
|
|
process.env.SAML_CERT = certWithoutHeader;
|
|
|
|
const actual = getCertificateContent(process.env.SAML_CERT);
|
|
expect(actual).toBe(certWithoutHeader);
|
|
});
|
|
|
|
it('should throw an error if SAML_CERT is a single-line string (with header, no newline characters)', () => {
|
|
process.env.SAML_CERT = certWithHeader.replace(/\n/g, '');
|
|
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
|
|
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
|
|
);
|
|
});
|
|
|
|
it('should load cert from a relative file path if SAML_CERT is valid', () => {
|
|
process.env.SAML_CERT = 'test.pem';
|
|
const resolvedPath = '/absolute/path/to/test.pem';
|
|
|
|
path.isAbsolute.mockReturnValue(false);
|
|
path.join.mockReturnValue(resolvedPath);
|
|
path.normalize.mockReturnValue(resolvedPath);
|
|
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.statSync.mockReturnValue({ isFile: () => true });
|
|
fs.readFileSync.mockReturnValue(certWithHeader);
|
|
|
|
const actual = getCertificateContent(process.env.SAML_CERT);
|
|
expect(actual).toBe(certWithHeader);
|
|
});
|
|
|
|
it('should load cert from an absolute file path if SAML_CERT is valid', () => {
|
|
process.env.SAML_CERT = '/absolute/path/to/test.pem';
|
|
|
|
path.isAbsolute.mockReturnValue(true);
|
|
path.normalize.mockReturnValue(process.env.SAML_CERT);
|
|
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.statSync.mockReturnValue({ isFile: () => true });
|
|
fs.readFileSync.mockReturnValue(certWithHeader);
|
|
|
|
const actual = getCertificateContent(process.env.SAML_CERT);
|
|
expect(actual).toBe(certWithHeader);
|
|
});
|
|
|
|
it('should throw an error if the file does not exist', () => {
|
|
process.env.SAML_CERT = 'missing.pem';
|
|
const resolvedPath = '/absolute/path/to/missing.pem';
|
|
|
|
path.isAbsolute.mockReturnValue(false);
|
|
path.join.mockReturnValue(resolvedPath);
|
|
path.normalize.mockReturnValue(resolvedPath);
|
|
|
|
fs.existsSync.mockReturnValue(false);
|
|
|
|
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
|
|
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
|
|
);
|
|
});
|
|
|
|
it('should throw an error if the file is not readable', () => {
|
|
process.env.SAML_CERT = 'unreadable.pem';
|
|
const resolvedPath = '/absolute/path/to/unreadable.pem';
|
|
|
|
path.isAbsolute.mockReturnValue(false);
|
|
path.join.mockReturnValue(resolvedPath);
|
|
path.normalize.mockReturnValue(resolvedPath);
|
|
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.statSync.mockReturnValue({ isFile: () => true });
|
|
fs.readFileSync.mockImplementation(() => {
|
|
throw new Error('Permission denied');
|
|
});
|
|
|
|
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
|
|
'Error reading certificate file: Permission denied',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('setupSaml', () => {
|
|
// Helper to wrap the verify callback in a promise
|
|
const validate = (profile) =>
|
|
new Promise((resolve, reject) => {
|
|
verifyCallback(profile, (err, user, details) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve({ user, details });
|
|
}
|
|
});
|
|
});
|
|
|
|
const baseProfile = {
|
|
nameID: 'saml-1234',
|
|
email: 'test@example.com',
|
|
given_name: 'First',
|
|
family_name: 'Last',
|
|
name: 'My Full Name',
|
|
username: 'flast',
|
|
picture: 'https://example.com/avatar.png',
|
|
custom_name: 'custom',
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
jest.clearAllMocks();
|
|
// Reset so the mock captures the regular (non-admin) callback on next setupSaml() call
|
|
verifyCallback = null;
|
|
|
|
// Configure mocks
|
|
const { findUser, createUser, updateUser } = require('~/models');
|
|
findUser.mockResolvedValue(null);
|
|
createUser.mockImplementation(async (userData) => ({
|
|
_id: 'mock-user-id',
|
|
...userData,
|
|
}));
|
|
updateUser.mockImplementation(async (id, userData) => ({
|
|
_id: id,
|
|
...userData,
|
|
}));
|
|
|
|
const cert = `
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
|
|
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
|
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
|
|
MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
|
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
|
AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
|
|
O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
|
|
VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
|
|
GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
|
|
4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
|
|
6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
|
|
DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
|
|
IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
|
|
Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
|
|
FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
|
|
Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
|
|
Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
|
|
meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
|
|
u7wlOSk+oFzDIO/UILIA
|
|
-----END CERTIFICATE-----
|
|
`;
|
|
|
|
// Reset environment variables
|
|
process.env.SAML_ENTRY_POINT = 'https://example.com/saml';
|
|
process.env.SAML_ISSUER = 'saml-issuer';
|
|
process.env.SAML_CERT = cert;
|
|
process.env.SAML_CALLBACK_URL = '/oauth/saml/callback';
|
|
delete process.env.SAML_EMAIL_CLAIM;
|
|
delete process.env.SAML_USERNAME_CLAIM;
|
|
delete process.env.SAML_GIVEN_NAME_CLAIM;
|
|
delete process.env.SAML_FAMILY_NAME_CLAIM;
|
|
delete process.env.SAML_PICTURE_CLAIM;
|
|
delete process.env.SAML_NAME_CLAIM;
|
|
|
|
// Simulate image download
|
|
const fakeBuffer = Buffer.from('fake image');
|
|
fetch.mockResolvedValue({
|
|
ok: true,
|
|
buffer: jest.fn().mockResolvedValue(fakeBuffer),
|
|
});
|
|
|
|
await setupSaml();
|
|
});
|
|
|
|
it('should create a new user with correct username when username claim exists', async () => {
|
|
const profile = { ...baseProfile };
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.username).toBe(profile.username);
|
|
expect(user.provider).toBe('saml');
|
|
expect(user.samlId).toBe(profile.nameID);
|
|
expect(user.email).toBe(profile.email);
|
|
expect(user.name).toBe(`${profile.given_name} ${profile.family_name}`);
|
|
});
|
|
|
|
it('should use given_name as username when username claim is missing', async () => {
|
|
const profile = { ...baseProfile };
|
|
delete profile.username;
|
|
const expectUsername = profile.given_name;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.username).toBe(expectUsername);
|
|
expect(user.provider).toBe('saml');
|
|
});
|
|
|
|
it('should use email as username when username and given_name are missing', async () => {
|
|
const profile = { ...baseProfile };
|
|
delete profile.username;
|
|
delete profile.given_name;
|
|
const expectUsername = profile.email;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.username).toBe(expectUsername);
|
|
expect(user.provider).toBe('saml');
|
|
});
|
|
|
|
it('should override username with SAML_USERNAME_CLAIM when set', async () => {
|
|
process.env.SAML_USERNAME_CLAIM = 'nameID';
|
|
const profile = { ...baseProfile };
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.username).toBe(profile.nameID);
|
|
expect(user.provider).toBe('saml');
|
|
});
|
|
|
|
it('should set the full name correctly when given_name and family_name exist', async () => {
|
|
const profile = { ...baseProfile };
|
|
const expectedFullName = `${profile.given_name} ${profile.family_name}`;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.name).toBe(expectedFullName);
|
|
});
|
|
|
|
it('should set the full name correctly when given_name exist', async () => {
|
|
const profile = { ...baseProfile };
|
|
delete profile.family_name;
|
|
const expectedFullName = profile.given_name;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.name).toBe(expectedFullName);
|
|
});
|
|
|
|
it('should set the full name correctly when family_name exist', async () => {
|
|
const profile = { ...baseProfile };
|
|
delete profile.given_name;
|
|
const expectedFullName = profile.family_name;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.name).toBe(expectedFullName);
|
|
});
|
|
|
|
it('should set the full name correctly when username exist', async () => {
|
|
const profile = { ...baseProfile };
|
|
delete profile.family_name;
|
|
delete profile.given_name;
|
|
const expectedFullName = profile.username;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.name).toBe(expectedFullName);
|
|
});
|
|
|
|
it('should set the full name correctly when email only exist', async () => {
|
|
const profile = { ...baseProfile };
|
|
delete profile.family_name;
|
|
delete profile.given_name;
|
|
delete profile.username;
|
|
const expectedFullName = profile.email;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.name).toBe(expectedFullName);
|
|
});
|
|
|
|
it('should set the full name correctly with SAML_NAME_CLAIM when set', async () => {
|
|
process.env.SAML_NAME_CLAIM = 'custom_name';
|
|
const profile = { ...baseProfile };
|
|
const expectedFullName = profile.custom_name;
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.name).toBe(expectedFullName);
|
|
});
|
|
|
|
it('should update an existing user on login', async () => {
|
|
// Set up findUser to return an existing user with saml provider
|
|
const { findUser } = require('~/models');
|
|
const existingUser = {
|
|
_id: 'existing-user-id',
|
|
provider: 'saml',
|
|
email: baseProfile.email,
|
|
samlId: '',
|
|
username: 'oldusername',
|
|
name: 'Old Name',
|
|
};
|
|
findUser.mockResolvedValue(existingUser);
|
|
|
|
const profile = { ...baseProfile };
|
|
const { user } = await validate(profile);
|
|
|
|
expect(user.provider).toBe('saml');
|
|
expect(user.samlId).toBe(baseProfile.nameID);
|
|
expect(user.username).toBe(baseProfile.username);
|
|
expect(user.name).toBe(`${baseProfile.given_name} ${baseProfile.family_name}`);
|
|
expect(user.email).toBe(baseProfile.email);
|
|
});
|
|
|
|
it('should block login when email exists with different provider', async () => {
|
|
// Set up findUser to return a user with different provider
|
|
const { findUser } = require('~/models');
|
|
const existingUser = {
|
|
_id: 'existing-user-id',
|
|
provider: 'google',
|
|
email: baseProfile.email,
|
|
googleId: 'some-google-id',
|
|
username: 'existinguser',
|
|
name: 'Existing User',
|
|
};
|
|
findUser.mockResolvedValue(existingUser);
|
|
|
|
const profile = { ...baseProfile };
|
|
const result = await validate(profile);
|
|
|
|
expect(result.user).toBe(false);
|
|
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
|
|
});
|
|
|
|
it('should attempt to download and save the avatar if picture is provided', async () => {
|
|
const profile = { ...baseProfile };
|
|
|
|
const { user } = await validate(profile);
|
|
|
|
expect(fetch).toHaveBeenCalled();
|
|
expect(user.avatar).toBe('/fake/path/to/avatar.png');
|
|
});
|
|
|
|
it('should not attempt to download avatar if picture is not provided', async () => {
|
|
const profile = { ...baseProfile };
|
|
delete profile.picture;
|
|
|
|
await validate(profile);
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass the found user to resolveAppConfigForUser', async () => {
|
|
const existingUser = {
|
|
_id: 'tenant-user-id',
|
|
provider: 'saml',
|
|
samlId: 'saml-1234',
|
|
email: 'test@example.com',
|
|
tenantId: 'tenant-c',
|
|
role: 'USER',
|
|
};
|
|
findUser.mockResolvedValue(existingUser);
|
|
|
|
const profile = { ...baseProfile };
|
|
await validate(profile);
|
|
|
|
expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser);
|
|
});
|
|
|
|
it('should use baseConfig for new SAML user without calling resolveAppConfigForUser', async () => {
|
|
const profile = { ...baseProfile };
|
|
await validate(profile);
|
|
|
|
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
|
|
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
|
});
|
|
|
|
it('should block login when tenant config restricts the domain', async () => {
|
|
const { isEmailDomainAllowed } = require('@librechat/api');
|
|
const existingUser = {
|
|
_id: 'tenant-blocked',
|
|
provider: 'saml',
|
|
samlId: 'saml-1234',
|
|
email: 'test@example.com',
|
|
tenantId: 'tenant-restrict',
|
|
role: 'USER',
|
|
};
|
|
findUser.mockResolvedValue(existingUser);
|
|
resolveAppConfigForUser.mockResolvedValue({
|
|
registration: { allowedDomains: ['other.com'] },
|
|
});
|
|
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
|
|
|
const profile = { ...baseProfile };
|
|
const { user } = await validate(profile);
|
|
expect(user).toBe(false);
|
|
});
|
|
});
|