UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

528 lines (439 loc) • 16.1 kB
/** * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies vi.mock('@/const/auth', () => ({ enableClerk: false, })); vi.mock('@/envs/app', () => ({ appEnv: { APP_URL: 'https://example.com', MARKET_BASE_URL: undefined, }, })); vi.mock('@/config/db', () => ({ serverDBEnv: { KEY_VAULTS_SECRET: 'test-secret-key', }, })); vi.mock('debug', () => ({ default: () => vi.fn(), })); describe('OIDC Provider - Market Client Integration', () => { const MARKET_CLIENT_ID = 'lobehub-market'; beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.resetModules(); }); describe('resolveClerkAccount', () => { it('should return undefined when Clerk is disabled', async () => { // Import with Clerk disabled vi.doMock('@/const/auth', () => ({ enableClerk: false, })); // Note: resolveClerkAccount is not exported, but we can test its behavior // through the findAccount method with market client // For now, we'll test the constants and basic setup const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); }); it('should handle market client ID constant', () => { // The MARKET_CLIENT_ID should match the client in config expect(MARKET_CLIENT_ID).toBe('lobehub-market'); }); }); describe('resolveClerkAccount - with Clerk enabled', () => { it('should resolve Clerk user with full profile', async () => { const mockClerkUser = { id: 'user_123', fullName: 'John Doe', firstName: 'John', lastName: 'Doe', username: 'johndoe', imageUrl: 'https://example.com/avatar.jpg', primaryEmailAddressId: 'email_1', emailAddresses: [ { id: 'email_1', emailAddress: 'john@example.com', verification: { status: 'verified' }, }, ], }; const mockClerkClient = { users: { getUser: vi.fn().mockResolvedValue(mockClerkUser), }, }; vi.doMock('@/const/auth', () => ({ enableClerk: true, })); vi.doMock('@clerk/nextjs/server', () => ({ clerkClient: vi.fn().mockResolvedValue(mockClerkClient), })); // Import the provider module to access resolveClerkAccount behavior const module = await import('./provider'); // Verify the module loads correctly expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); vi.doUnmock('@clerk/nextjs/server'); }); it('should handle Clerk user with only username', async () => { const mockClerkUser = { id: 'user_123', fullName: null, firstName: null, lastName: null, username: 'johndoe', imageUrl: null, primaryEmailAddressId: 'email_1', emailAddresses: [ { id: 'email_1', emailAddress: 'john@example.com', verification: { status: 'verified' }, }, ], }; const mockClerkClient = { users: { getUser: vi.fn().mockResolvedValue(mockClerkUser), }, }; vi.doMock('@/const/auth', () => ({ enableClerk: true, })); vi.doMock('@clerk/nextjs/server', () => ({ clerkClient: vi.fn().mockResolvedValue(mockClerkClient), })); const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); vi.doUnmock('@clerk/nextjs/server'); }); it('should handle Clerk user with firstName and lastName', async () => { const mockClerkUser = { id: 'user_123', fullName: null, firstName: 'John', lastName: 'Doe', username: null, imageUrl: null, primaryEmailAddressId: 'email_1', emailAddresses: [ { id: 'email_1', emailAddress: 'john@example.com', verification: { status: 'verified' }, }, ], }; const mockClerkClient = { users: { getUser: vi.fn().mockResolvedValue(mockClerkUser), }, }; vi.doMock('@/const/auth', () => ({ enableClerk: true, })); vi.doMock('@clerk/nextjs/server', () => ({ clerkClient: vi.fn().mockResolvedValue(mockClerkClient), })); const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); vi.doUnmock('@clerk/nextjs/server'); }); it('should handle Clerk user not found', async () => { const mockClerkClient = { users: { getUser: vi.fn().mockResolvedValue(null), }, }; vi.doMock('@/const/auth', () => ({ enableClerk: true, })); vi.doMock('@clerk/nextjs/server', () => ({ clerkClient: vi.fn().mockResolvedValue(mockClerkClient), })); const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); vi.doUnmock('@clerk/nextjs/server'); }); it('should handle Clerk API error', async () => { const mockClerkClient = { users: { getUser: vi.fn().mockRejectedValue(new Error('Clerk API error')), }, }; vi.doMock('@/const/auth', () => ({ enableClerk: true, })); vi.doMock('@clerk/nextjs/server', () => ({ clerkClient: vi.fn().mockResolvedValue(mockClerkClient), })); const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); vi.doUnmock('@clerk/nextjs/server'); }); it('should handle email without verification', async () => { const mockClerkUser = { id: 'user_123', fullName: 'John Doe', firstName: 'John', lastName: 'Doe', username: 'johndoe', imageUrl: 'https://example.com/avatar.jpg', primaryEmailAddressId: 'email_1', emailAddresses: [ { id: 'email_1', emailAddress: 'john@example.com', verification: null, }, ], }; const mockClerkClient = { users: { getUser: vi.fn().mockResolvedValue(mockClerkUser), }, }; vi.doMock('@/const/auth', () => ({ enableClerk: true, })); vi.doMock('@clerk/nextjs/server', () => ({ clerkClient: vi.fn().mockResolvedValue(mockClerkClient), })); const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); vi.doUnmock('@clerk/nextjs/server'); }); it('should use first email when no primary email is set', async () => { const mockClerkUser = { id: 'user_123', fullName: 'John Doe', firstName: 'John', lastName: 'Doe', username: 'johndoe', imageUrl: 'https://example.com/avatar.jpg', primaryEmailAddressId: null, emailAddresses: [ { id: 'email_2', emailAddress: 'john.first@example.com', verification: { status: 'verified' }, }, { id: 'email_3', emailAddress: 'john.second@example.com', verification: { status: 'verified' }, }, ], }; const mockClerkClient = { users: { getUser: vi.fn().mockResolvedValue(mockClerkUser), }, }; vi.doMock('@/const/auth', () => ({ enableClerk: true, })); vi.doMock('@clerk/nextjs/server', () => ({ clerkClient: vi.fn().mockResolvedValue(mockClerkClient), })); const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/const/auth'); vi.doUnmock('@clerk/nextjs/server'); }); }); describe('Market Client Logic', () => { it('should identify market client correctly', () => { // The market client should route to Clerk resolution expect(MARKET_CLIENT_ID).toBe('lobehub-market'); }); it('should have market client in default clients', async () => { vi.doMock('@/envs/app', () => ({ appEnv: { APP_URL: 'https://example.com', MARKET_BASE_URL: 'https://market.lobehub.com', }, })); const { defaultClients } = await import('./config'); const marketClient = defaultClients.find((c) => c.client_id === MARKET_CLIENT_ID); expect(marketClient).toBeDefined(); expect(marketClient?.client_id).toBe('lobehub-market'); expect(marketClient?.client_name).toBe('LobeHub Marketplace'); vi.doUnmock('@/envs/app'); }); }); describe('Provider Configuration', () => { it('should export API_AUDIENCE constant', async () => { vi.doMock('@/envs/app', () => ({ appEnv: { APP_URL: 'https://example.com', MARKET_BASE_URL: undefined, }, })); const module = await import('./provider'); expect(module.API_AUDIENCE).toBe('urn:lobehub:chat'); vi.doUnmock('@/envs/app'); }); it('should have createOIDCProvider function', async () => { vi.doMock('@/envs/app', () => ({ appEnv: { APP_URL: 'https://example.com', MARKET_BASE_URL: undefined, }, })); const module = await import('./provider'); expect(module.createOIDCProvider).toBeDefined(); expect(typeof module.createOIDCProvider).toBe('function'); vi.doUnmock('@/envs/app'); }); }); describe('Name Resolution Priority', () => { it('should prioritize fullName over firstName+lastName', () => { const priorities = ['fullName', 'firstName + lastName', 'username', 'id']; // Test the priority logic expect(priorities[0]).toBe('fullName'); expect(priorities[1]).toBe('firstName + lastName'); expect(priorities[2]).toBe('username'); expect(priorities[3]).toBe('id'); }); }); describe('Claims Generation', () => { it('should include profile claims when profile scope is requested', () => { const scopes = ['openid', 'profile', 'email']; expect(scopes).toContain('profile'); }); it('should include email claims when email scope is requested', () => { const scopes = ['openid', 'profile', 'email']; expect(scopes).toContain('email'); }); it('should always include sub claim', () => { const requiredClaims = ['sub']; expect(requiredClaims).toContain('sub'); }); }); describe('Non-Market Client Logic (Default Path)', () => { it('should use UserModel for non-market clients (desktop client)', () => { // Desktop client should use the default user database lookup const desktopClientId = 'lobehub-desktop'; expect(desktopClientId).not.toBe(MARKET_CLIENT_ID); }); it('should use UserModel for non-market clients (mobile client)', () => { // Mobile client should use the default user database lookup const mobileClientId = 'lobehub-mobile'; expect(mobileClientId).not.toBe(MARKET_CLIENT_ID); }); it('should validate non-market client IDs are different from market client', () => { const nonMarketClients = ['lobehub-desktop', 'lobehub-mobile']; nonMarketClients.forEach((clientId) => { expect(clientId).not.toBe(MARKET_CLIENT_ID); }); }); }); describe('Account ID Priority Logic', () => { it('should prioritize externalAccountId over session accountId', () => { const priorities = { first: 'externalAccountId', second: 'ctx.oidc.session.accountId', third: 'parameter id', }; expect(priorities.first).toBe('externalAccountId'); expect(priorities.second).toBe('ctx.oidc.session.accountId'); expect(priorities.third).toBe('parameter id'); }); it('should document account ID resolution priority', () => { // Priority: 1. externalAccountId 2. ctx.oidc.session?.accountId 3. id parameter const accountIdPriority = [ 'externalAccountId (highest)', 'ctx.oidc.session.accountId (medium)', 'id parameter (lowest)', ]; expect(accountIdPriority).toHaveLength(3); expect(accountIdPriority[0]).toContain('externalAccountId'); expect(accountIdPriority[1]).toContain('ctx.oidc.session.accountId'); expect(accountIdPriority[2]).toContain('id parameter'); }); }); describe('Business Logic Scenarios', () => { describe('Scenario 1: Market Client + Clerk Authentication', () => { it('should route market client to Clerk when enableClerk is true', () => { // Business: When user accesses from marketplace, use Clerk for SSO const scenario = { client: 'lobehub-market', authProvider: 'Clerk', useCase: 'Marketplace SSO - users login via Clerk on marketplace', }; expect(scenario.client).toBe(MARKET_CLIENT_ID); expect(scenario.authProvider).toBe('Clerk'); }); it('should return undefined for market client when Clerk is disabled', () => { // Business: Market requires Clerk, if disabled, auth fails const scenario = { client: 'lobehub-market', clerkEnabled: false, expectedResult: 'undefined (auth fails)', }; expect(scenario.expectedResult).toBe('undefined (auth fails)'); }); }); describe('Scenario 2: Desktop Client + Local Database', () => { it('should use local UserModel for desktop client', () => { // Business: Desktop app uses local database for user management const scenario = { client: 'lobehub-desktop', authProvider: 'UserModel (Local Database)', useCase: 'Desktop app with local/self-hosted user database', }; expect(scenario.client).toBe('lobehub-desktop'); expect(scenario.authProvider).toBe('UserModel (Local Database)'); }); }); describe('Scenario 3: Mobile Client + Local Database', () => { it('should use local UserModel for mobile client', () => { // Business: Mobile app uses local database for user management const scenario = { client: 'lobehub-mobile', authProvider: 'UserModel (Local Database)', useCase: 'Mobile app with local/self-hosted user database', }; expect(scenario.client).toBe('lobehub-mobile'); expect(scenario.authProvider).toBe('UserModel (Local Database)'); }); }); describe('Scenario 4: Claims Generation by Client Type', () => { it('should generate Clerk-based claims for market client', () => { // Business: Market users get profile/email from Clerk const marketClaims = { source: 'Clerk API', fields: ['sub', 'name', 'picture', 'email', 'email_verified'], nameResolution: 'fullName || firstName+lastName || username || id', }; expect(marketClaims.source).toBe('Clerk API'); expect(marketClaims.fields).toContain('name'); expect(marketClaims.fields).toContain('email'); }); it('should generate database-based claims for non-market clients', () => { // Business: Desktop/Mobile users get profile/email from local DB const localClaims = { source: 'UserModel (PostgreSQL/PGLite)', fields: ['sub', 'name', 'picture', 'email', 'email_verified'], nameResolution: 'fullName || username || firstName+lastName', }; expect(localClaims.source).toBe('UserModel (PostgreSQL/PGLite)'); expect(localClaims.fields).toContain('name'); expect(localClaims.fields).toContain('email'); }); }); }); });