Skip to main content
Glama
larksuite

Feishu/Lark OpenAPI MCP

Official
by larksuite
login-handler.test.ts21.4 kB
import { LoginHandler } from '../../src/cli/login-handler'; import { authStore } from '../../src/auth/store'; // Mock all dependencies before importing jest.mock('../../src/auth/store', () => ({ authStore: { getLocalAccessToken: jest.fn(), removeLocalAccessToken: jest.fn(), removeAllLocalAccessTokens: jest.fn(), getAllLocalAccessTokens: jest.fn(), getToken: jest.fn(), }, })); // Mock isTokenExpired jest.mock('../../src/auth/utils', () => ({ isTokenExpired: jest.fn(), })); // Mock the entire handler-local module jest.mock('../../src/auth/handler/handler-local', () => ({ LarkAuthHandlerLocal: jest.fn().mockImplementation(() => ({ reAuthorize: jest.fn(), setupRoutes: jest.fn(), callbackUrl: 'http://localhost:3000/callback', })), })); // Mock provider jest.mock('../../src/auth/provider', () => ({ LarkProxyOAuthServerProvider: jest.fn().mockImplementation(() => ({})), })); // Mock SDK router jest.mock('@modelcontextprotocol/sdk/server/auth/router.js', () => ({ mcpAuthRouter: jest.fn(() => (req: any, res: any, next: any) => next()), })); // Mock express jest.mock('express', () => { const mockApp = { use: jest.fn(), get: jest.fn(), listen: jest.fn(), }; const expressFn = jest.fn(() => mockApp); (expressFn as any).json = jest.fn(() => (req: any, res: any, next: any) => next()); return expressFn; }); // Mock open jest.mock('open', () => jest.fn()); // Import the mocked class after setting up mocks import { LarkAuthHandlerLocal } from '../../src/auth/handler/handler-local'; import { isTokenExpired } from '../../src/auth/utils'; // Mock console methods const consoleSpy = { log: jest.spyOn(console, 'log').mockImplementation(), error: jest.spyOn(console, 'error').mockImplementation(), }; // Mock process.exit to prevent tests from actually exiting const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); describe('LoginHandler', () => { beforeEach(() => { jest.clearAllMocks(); }); afterAll(() => { consoleSpy.log.mockRestore(); consoleSpy.error.mockRestore(); mockProcessExit.mockRestore(); }); describe('checkTokenWithTimeout', () => { it('应该在找到token时返回true', async () => { // Mock authStore to return a token immediately (authStore.getLocalAccessToken as jest.Mock).mockResolvedValue('found-token'); const result = await LoginHandler.checkTokenWithTimeout(5000, 'test-app'); expect(result).toBe(true); expect(authStore.getLocalAccessToken).toHaveBeenCalledWith('test-app'); }); it('应该在超时时返回false', async () => { // Mock authStore to never return a token (authStore.getLocalAccessToken as jest.Mock).mockResolvedValue(null); // Use a very short timeout to test quickly const result = await LoginHandler.checkTokenWithTimeout(100, 'test-app'); expect(result).toBe(false); expect(authStore.getLocalAccessToken).toHaveBeenCalledWith('test-app'); }); }); describe('handleLogin', () => { it('应该显示错误信息当缺少必需凭证时', async () => { const testCases = [ { appId: '', appSecret: 'test-secret', description: '缺少appId' }, { appId: 'test-app-id', appSecret: '', description: '缺少appSecret' }, { appId: '', appSecret: '', description: '缺少appId和appSecret' }, ]; for (const testCase of testCases) { const options = { appId: testCase.appId, appSecret: testCase.appSecret, domain: 'test.domain.com', host: 'localhost', port: '3000', }; await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(consoleSpy.error).toHaveBeenCalledWith( 'Error: Missing App Credentials (appId and appSecret are required for login)', ); expect(mockProcessExit).toHaveBeenCalledWith(1); // 清除mock调用记录,为下一个测试用例准备 jest.clearAllMocks(); } }); it('应该启动OAuth登录流程当需要新授权URL时', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', scope: ['test-scope'], }; // Setup mock for successful authorization URL case const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: 'http://test-auth-url.com', accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:3000/callback', }) as any, ); // Mock checkTokenWithTimeout to resolve immediately jest.spyOn(LoginHandler, 'checkTokenWithTimeout').mockResolvedValue(true); // This test expects process.exit(0) to be called when checkTokenWithTimeout returns true await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(mockSetupRoutes).toHaveBeenCalled(); expect(mockReAuthorize).toHaveBeenCalled(); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该启动OAuth登录流程当没有提供scope时', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', }; const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: 'http://test-auth-url.com', accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:3000/callback', }) as any, ); jest.spyOn(LoginHandler, 'checkTokenWithTimeout').mockResolvedValue(false); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(mockProcessExit).toHaveBeenCalledWith(1); }); it('应该显示已登录信息当有有效token时', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', }; // 现在的逻辑:如果reAuthorize没有返回authorizeUrl,直接exit(1) const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: '', // 空字符串表示不需要重新授权 accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:3000/callback', }) as any, ); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(mockProcessExit).toHaveBeenCalledWith(1); }); it('应该处理登录错误', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', }; const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockRejectedValue(new Error('Auth failed')); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:3000/callback', }) as any, ); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(consoleSpy.error).toHaveBeenCalledWith('❌ Login failed:', expect.any(Error)); expect(mockProcessExit).toHaveBeenCalledWith(1); }); it('应该正确创建LarkAuthHandlerLocal实例并转换端口号', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '8080', scope: ['test-scope'], }; let capturedApp: any = null; let capturedConfig: any = null; const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: '', // 空URL会导致exit(1) accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation((app, config) => { capturedApp = app; capturedConfig = config; return { reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:8080/callback', } as any; }); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(LarkAuthHandlerLocal).toHaveBeenCalled(); expect(capturedApp).toBeDefined(); expect(capturedConfig).toEqual({ port: 8080, // Should be converted to number host: 'localhost', domain: 'test.domain.com', appId: 'test-app-id', appSecret: 'test-app-secret', scope: ['test-scope'], }); expect(typeof capturedConfig.port).toBe('number'); expect(mockProcessExit).toHaveBeenCalledWith(1); }); it('应该处理reAuthorize返回空值的边界情况', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', }; const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: '', accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:3000/callback', }) as any, ); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(mockProcessExit).toHaveBeenCalledWith(1); }); }); describe('handleLogout', () => { it('应该成功登出并清除token', async () => { const appId = 'test-app-id'; (authStore.getLocalAccessToken as jest.Mock).mockResolvedValue('valid-token'); (authStore.removeLocalAccessToken as jest.Mock).mockResolvedValue(undefined); await expect(LoginHandler.handleLogout(appId)).rejects.toThrow('process.exit called'); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该显示没有活动会话信息当token为falsy时', async () => { const appId = 'test-app-id'; (authStore.getLocalAccessToken as jest.Mock).mockResolvedValue(null); await expect(LoginHandler.handleLogout(appId)).rejects.toThrow('process.exit called'); expect(authStore.removeLocalAccessToken).not.toHaveBeenCalled(); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该处理缺少appId的情况并登出所有应用', async () => { (authStore.removeAllLocalAccessTokens as jest.Mock).mockResolvedValue(undefined); await expect(LoginHandler.handleLogout()).rejects.toThrow('process.exit called'); expect(authStore.removeAllLocalAccessTokens).toHaveBeenCalled(); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该处理getLocalAccessToken错误', async () => { const appId = 'test-app-id'; (authStore.getLocalAccessToken as jest.Mock).mockRejectedValue(new Error('Storage error')); await expect(LoginHandler.handleLogout(appId)).rejects.toThrow('process.exit called'); expect(consoleSpy.error).toHaveBeenCalledWith('❌ Logout failed:', expect.any(Error)); expect(mockProcessExit).toHaveBeenCalledWith(1); }); it('应该处理removeLocalAccessToken错误', async () => { const appId = 'test-app-id'; (authStore.getLocalAccessToken as jest.Mock).mockResolvedValue('valid-token'); (authStore.removeLocalAccessToken as jest.Mock).mockRejectedValue(new Error('Remove failed')); await expect(LoginHandler.handleLogout(appId)).rejects.toThrow('process.exit called'); expect(authStore.removeLocalAccessToken).toHaveBeenCalledWith(appId); expect(mockProcessExit).toHaveBeenCalledWith(1); }); }); describe('handleWhoAmI', () => { it('应该显示没有活动会话信息当没有token时', async () => { (authStore.getAllLocalAccessTokens as jest.Mock).mockResolvedValue({}); await expect(LoginHandler.handleWhoAmI()).rejects.toThrow('process.exit called'); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该显示单个应用的登录会话信息', async () => { const mockTokens = { 'test-app-id': 'test-access-token-1', }; const mockTokenInfo = { clientId: 'test-client-id', token: 'test-access-token-1', scopes: ['test-scope'], expiresAt: Date.now() + 7200000, extra: { refreshToken: 'test-refresh-token', appId: 'test-app-id', appSecret: 'test-app-secret', }, }; (authStore.getAllLocalAccessTokens as jest.Mock).mockResolvedValue(mockTokens); (authStore.getToken as jest.Mock).mockResolvedValue(mockTokenInfo); (isTokenExpired as jest.Mock).mockReturnValue(false); await expect(LoginHandler.handleWhoAmI()).rejects.toThrow('process.exit called'); expect(authStore.getToken).toHaveBeenCalledWith('test-access-token-1'); expect(isTokenExpired).toHaveBeenCalledWith(mockTokenInfo); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该显示多个应用的登录会话信息', async () => { const mockTokens = { 'test-app-id-1': 'test-access-token-1', 'test-app-id-2': 'test-access-token-2', }; const mockTokenInfo1 = { clientId: 'test-client-id-1', token: 'test-access-token-1', scopes: ['scope1'], expiresAt: Date.now() + 7200000, extra: { refreshToken: 'test-refresh-token-1', appId: 'test-app-id-1', appSecret: 'test-app-secret-1', }, }; const mockTokenInfo2 = { clientId: 'test-client-id-2', token: 'test-access-token-2', scopes: ['scope2'], expiresAt: Date.now() - 3700000, // Expired token extra: { refreshToken: 'test-refresh-token-2', appId: 'test-app-id-2', appSecret: 'test-app-secret-2', }, }; (authStore.getAllLocalAccessTokens as jest.Mock).mockResolvedValue(mockTokens); (authStore.getToken as jest.Mock).mockResolvedValueOnce(mockTokenInfo1).mockResolvedValueOnce(mockTokenInfo2); (isTokenExpired as jest.Mock) .mockReturnValueOnce(false) // First token not expired .mockReturnValueOnce(true); // Second token expired await expect(LoginHandler.handleWhoAmI()).rejects.toThrow('process.exit called'); expect(authStore.getToken).toHaveBeenCalledWith('test-access-token-1'); expect(authStore.getToken).toHaveBeenCalledWith('test-access-token-2'); expect(isTokenExpired).toHaveBeenCalledWith(mockTokenInfo1); expect(isTokenExpired).toHaveBeenCalledWith(mockTokenInfo2); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该处理获取token信息时的错误', async () => { const mockTokens = { 'test-app-id': 'test-access-token-1', }; (authStore.getAllLocalAccessTokens as jest.Mock).mockResolvedValue(mockTokens); (authStore.getToken as jest.Mock).mockRejectedValue(new Error('Token retrieval failed')); // handleWhoAmI doesn't have try-catch, so the error will bubble up await expect(LoginHandler.handleWhoAmI()).rejects.toThrow('Token retrieval failed'); expect(authStore.getAllLocalAccessTokens).toHaveBeenCalled(); expect(authStore.getToken).toHaveBeenCalledWith('test-access-token-1'); }); it('应该处理isTokenExpired抛出错误的情况', async () => { const mockTokens = { 'test-app-id': 'test-access-token-1', }; const mockTokenInfo = { accessToken: 'test-access-token-1', expiresIn: 7200, createdAt: Date.now(), }; (authStore.getAllLocalAccessTokens as jest.Mock).mockResolvedValue(mockTokens); (authStore.getToken as jest.Mock).mockResolvedValue(mockTokenInfo); (isTokenExpired as jest.Mock).mockImplementation(() => { throw new Error('Token validation failed'); }); // handleWhoAmI doesn't have try-catch, so the error will bubble up await expect(LoginHandler.handleWhoAmI()).rejects.toThrow('Token validation failed'); expect(authStore.getToken).toHaveBeenCalledWith('test-access-token-1'); expect(isTokenExpired).toHaveBeenCalledWith(mockTokenInfo); }); }); describe('handleLogin - 额外的边界测试', () => { it('应该正确处理authHandler.callbackUrl属性', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', }; const mockCallbackUrl = 'http://localhost:3000/callback'; const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: 'http://test-auth-url.com', accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: mockCallbackUrl, }) as any, ); jest.spyOn(LoginHandler, 'checkTokenWithTimeout').mockResolvedValue(true); (authStore.removeLocalAccessToken as jest.Mock).mockResolvedValue(undefined); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); expect(authStore.removeLocalAccessToken).toHaveBeenCalledWith(options.appId); expect(mockProcessExit).toHaveBeenCalledWith(0); }); it('应该使用默认timeout值', async () => { const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', // 没有提供 timeout }; const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: 'http://test-auth-url.com', accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:3000/callback', }) as any, ); const checkTokenSpy = jest.spyOn(LoginHandler, 'checkTokenWithTimeout').mockResolvedValue(false); (authStore.removeLocalAccessToken as jest.Mock).mockResolvedValue(undefined); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); // 验证使用了默认的60000毫秒timeout expect(checkTokenSpy).toHaveBeenCalledWith(60000, 'test-app-id'); expect(mockProcessExit).toHaveBeenCalledWith(1); }); it('应该使用自定义timeout值', async () => { const customTimeout = 30000; const options = { appId: 'test-app-id', appSecret: 'test-app-secret', domain: 'test.domain.com', host: 'localhost', port: '3000', timeout: customTimeout, }; const mockSetupRoutes = jest.fn(); const mockReAuthorize = jest.fn().mockResolvedValue({ authorizeUrl: 'http://test-auth-url.com', accessToken: '', }); (LarkAuthHandlerLocal as jest.MockedClass<typeof LarkAuthHandlerLocal>).mockImplementation( () => ({ reAuthorize: mockReAuthorize, setupRoutes: mockSetupRoutes, callbackUrl: 'http://localhost:3000/callback', }) as any, ); const checkTokenSpy = jest.spyOn(LoginHandler, 'checkTokenWithTimeout').mockResolvedValue(true); (authStore.removeLocalAccessToken as jest.Mock).mockResolvedValue(undefined); await expect(LoginHandler.handleLogin(options)).rejects.toThrow('process.exit called'); // 验证使用了自定义timeout值 expect(checkTokenSpy).toHaveBeenCalledWith(customTimeout, 'test-app-id'); expect(mockProcessExit).toHaveBeenCalledWith(0); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/larksuite/lark-openapi-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server