diff options
Diffstat (limited to 'packages/core/src/mcp/oauth-provider.test.ts')
| -rw-r--r-- | packages/core/src/mcp/oauth-provider.test.ts | 296 |
1 files changed, 213 insertions, 83 deletions
diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 3991aecc..2163d6bc 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -29,6 +29,55 @@ import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js'; const mockFetch = vi.fn(); global.fetch = mockFetch; +// Helper function to create mock fetch responses with proper headers +const createMockResponse = (options: { + ok: boolean; + status?: number; + contentType?: string; + text?: string | (() => Promise<string>); + json?: unknown | (() => Promise<unknown>); +}) => { + const response: { + ok: boolean; + status?: number; + headers: { + get: (name: string) => string | null; + }; + text?: () => Promise<string>; + json?: () => Promise<unknown>; + } = { + ok: options.ok, + headers: { + get: (name: string) => { + if (name.toLowerCase() === 'content-type') { + return options.contentType || null; + } + return null; + }, + }, + }; + + if (options.status !== undefined) { + response.status = options.status; + } + + if (options.text !== undefined) { + response.text = + typeof options.text === 'string' + ? () => Promise.resolve(options.text as string) + : (options.text as () => Promise<string>); + } + + if (options.json !== undefined) { + response.json = + typeof options.json === 'function' + ? (options.json as () => Promise<unknown>) + : () => Promise.resolve(options.json); + } + + return response; +}; + // Define a reusable mock server with .listen, .close, and .on methods const mockHttpServer = { listen: vi.fn(), @@ -133,10 +182,14 @@ describe('MCPOAuthProvider', () => { }); // Mock token exchange - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); const result = await MCPOAuthProvider.authenticate( 'test-server', @@ -165,7 +218,11 @@ describe('MCPOAuthProvider', () => { it('should handle OAuth discovery when no authorization URL provided', async () => { // Use a mutable config object - const configWithoutAuth: MCPOAuthConfig = { ...mockConfig }; + const configWithoutAuth: MCPOAuthConfig = { + ...mockConfig, + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }; delete configWithoutAuth.authorizationUrl; delete configWithoutAuth.tokenUrl; @@ -179,21 +236,30 @@ describe('MCPOAuthProvider', () => { scopes_supported: ['read', 'write'], }; + // Mock HEAD request for WWW-Authenticate check mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockResourceMetadata), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockAuthServerMetadata), - }); - - // Patch config after discovery - configWithoutAuth.authorizationUrl = - mockAuthServerMetadata.authorization_endpoint; - configWithoutAuth.tokenUrl = mockAuthServerMetadata.token_endpoint; - configWithoutAuth.scopes = mockAuthServerMetadata.scopes_supported; + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockResourceMetadata), + json: mockResourceMetadata, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockAuthServerMetadata), + json: mockAuthServerMetadata, + }), + ); // Setup callback handler let callbackHandler: unknown; @@ -220,10 +286,14 @@ describe('MCPOAuthProvider', () => { }); // Mock token exchange with discovered endpoint - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); const result = await MCPOAuthProvider.authenticate( 'test-server', @@ -236,7 +306,9 @@ describe('MCPOAuthProvider', () => { 'https://discovered.auth.com/token', expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: expect.objectContaining({ + 'Content-Type': 'application/x-www-form-urlencoded', + }), }), ); }); @@ -261,14 +333,22 @@ describe('MCPOAuthProvider', () => { }; mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockAuthServerMetadata), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockRegistrationResponse), - }); + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockAuthServerMetadata), + json: mockAuthServerMetadata, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockRegistrationResponse), + json: mockRegistrationResponse, + }), + ); // Setup callback handler let callbackHandler: unknown; @@ -295,10 +375,14 @@ describe('MCPOAuthProvider', () => { }); // Mock token exchange - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); const result = await MCPOAuthProvider.authenticate( 'test-server', @@ -397,15 +481,18 @@ describe('MCPOAuthProvider', () => { }, 10); }); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - text: () => Promise.resolve('Invalid grant'), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 400, + contentType: 'application/x-www-form-urlencoded', + text: 'error=invalid_grant&error_description=Invalid grant', + }), + ); await expect( MCPOAuthProvider.authenticate('test-server', mockConfig), - ).rejects.toThrow('Token exchange failed: 400 - Invalid grant'); + ).rejects.toThrow('Token exchange failed: invalid_grant - Invalid grant'); }); it('should handle callback timeout', async () => { @@ -445,10 +532,14 @@ describe('MCPOAuthProvider', () => { refresh_token: 'new_refresh_token', }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(refreshResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(refreshResponse), + json: refreshResponse, + }), + ); const result = await MCPOAuthProvider.refreshAccessToken( mockConfig, @@ -461,17 +552,24 @@ describe('MCPOAuthProvider', () => { 'https://auth.example.com/token', expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json, application/x-www-form-urlencoded', + }, body: expect.stringContaining('grant_type=refresh_token'), }), ); }); it('should include client secret in refresh request when available', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); await MCPOAuthProvider.refreshAccessToken( mockConfig, @@ -484,11 +582,14 @@ describe('MCPOAuthProvider', () => { }); it('should handle refresh token failure', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - text: () => Promise.resolve('Invalid refresh token'), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 400, + contentType: 'application/x-www-form-urlencoded', + text: 'error=invalid_request&error_description=Invalid refresh token', + }), + ); await expect( MCPOAuthProvider.refreshAccessToken( @@ -496,7 +597,9 @@ describe('MCPOAuthProvider', () => { 'invalid_refresh_token', 'https://auth.example.com/token', ), - ).rejects.toThrow('Token refresh failed: 400 - Invalid refresh token'); + ).rejects.toThrow( + 'Token refresh failed: invalid_request - Invalid refresh token', + ); }); }); @@ -544,10 +647,14 @@ describe('MCPOAuthProvider', () => { refresh_token: 'new_refresh_token', }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(refreshResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(refreshResponse), + json: refreshResponse, + }), + ); const result = await MCPOAuthProvider.getValidToken( 'test-server', @@ -590,11 +697,14 @@ describe('MCPOAuthProvider', () => { vi.mocked(MCPOAuthTokenStorage.isTokenExpired).mockReturnValue(true); vi.mocked(MCPOAuthTokenStorage.removeToken).mockResolvedValue(undefined); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - text: () => Promise.resolve('Invalid refresh token'), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 400, + contentType: 'application/x-www-form-urlencoded', + text: 'error=invalid_request&error_description=Invalid refresh token', + }), + ); const result = await MCPOAuthProvider.getValidToken( 'test-server', @@ -664,10 +774,14 @@ describe('MCPOAuthProvider', () => { }, 10); }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); await MCPOAuthProvider.authenticate('test-server', mockConfig); @@ -709,12 +823,20 @@ describe('MCPOAuthProvider', () => { }, 10); }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); - await MCPOAuthProvider.authenticate('test-server', mockConfig); + await MCPOAuthProvider.authenticate( + 'test-server', + mockConfig, + 'https://auth.example.com', + ); expect(capturedUrl).toBeDefined(); expect(capturedUrl!).toContain('response_type=code'); @@ -757,10 +879,14 @@ describe('MCPOAuthProvider', () => { }, 10); }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); const configWithParamsInUrl = { ...mockConfig, @@ -806,10 +932,14 @@ describe('MCPOAuthProvider', () => { }, 10); }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockTokenResponse), - }); + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); const configWithFragment = { ...mockConfig, |
