1118 lines
41 KiB
TypeScript
1118 lines
41 KiB
TypeScript
import { withOAuth, withLogging, applyMiddlewares, createMiddleware } from '../../src/client/middleware.js';
|
|
import { OAuthClientProvider } from '../../src/client/auth.js';
|
|
import { FetchLike } from '../../src/shared/transport.js';
|
|
import { MockInstance, Mocked, MockedFunction } from 'vitest';
|
|
|
|
vi.mock('../../src/client/auth.js', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/client/auth.js')>('../../src/client/auth.js');
|
|
return {
|
|
...actual,
|
|
auth: vi.fn(),
|
|
extractWWWAuthenticateParams: vi.fn()
|
|
};
|
|
});
|
|
|
|
import { auth, extractWWWAuthenticateParams } from '../../src/client/auth.js';
|
|
|
|
const mockAuth = auth as MockedFunction<typeof auth>;
|
|
const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as MockedFunction<typeof extractWWWAuthenticateParams>;
|
|
|
|
describe('withOAuth', () => {
|
|
let mockProvider: Mocked<OAuthClientProvider>;
|
|
let mockFetch: MockedFunction<FetchLike>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockProvider = {
|
|
get redirectUrl() {
|
|
return 'http://localhost/callback';
|
|
},
|
|
get clientMetadata() {
|
|
return { redirect_uris: ['http://localhost/callback'] };
|
|
},
|
|
tokens: vi.fn(),
|
|
saveTokens: vi.fn(),
|
|
clientInformation: vi.fn(),
|
|
redirectToAuthorization: vi.fn(),
|
|
saveCodeVerifier: vi.fn(),
|
|
codeVerifier: vi.fn(),
|
|
invalidateCredentials: vi.fn()
|
|
};
|
|
|
|
mockFetch = vi.fn();
|
|
});
|
|
|
|
it('should add Authorization header when tokens are available (with explicit baseUrl)', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(new Response('success', { status: 200 }));
|
|
|
|
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://api.example.com/data',
|
|
expect.objectContaining({
|
|
headers: expect.any(Headers)
|
|
})
|
|
);
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Authorization')).toBe('Bearer test-token');
|
|
});
|
|
|
|
it('should add Authorization header when tokens are available (without baseUrl)', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(new Response('success', { status: 200 }));
|
|
|
|
// Test without baseUrl - should extract from request URL
|
|
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://api.example.com/data',
|
|
expect.objectContaining({
|
|
headers: expect.any(Headers)
|
|
})
|
|
);
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Authorization')).toBe('Bearer test-token');
|
|
});
|
|
|
|
it('should handle requests without tokens (without baseUrl)', async () => {
|
|
mockProvider.tokens.mockResolvedValue(undefined);
|
|
mockFetch.mockResolvedValue(new Response('success', { status: 200 }));
|
|
|
|
// Test without baseUrl
|
|
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Authorization')).toBeNull();
|
|
});
|
|
|
|
it('should retry request after successful auth on 401 response (with explicit baseUrl)', async () => {
|
|
mockProvider.tokens
|
|
.mockResolvedValueOnce({
|
|
access_token: 'old-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
})
|
|
.mockResolvedValueOnce({
|
|
access_token: 'new-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
const unauthorizedResponse = new Response('Unauthorized', {
|
|
status: 401,
|
|
headers: { 'www-authenticate': 'Bearer realm="oauth"' }
|
|
});
|
|
const successResponse = new Response('success', { status: 200 });
|
|
|
|
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
|
|
|
|
const mockWWWAuthenticateParams = {
|
|
resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'),
|
|
scope: 'read'
|
|
};
|
|
mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams);
|
|
mockAuth.mockResolvedValue('AUTHORIZED');
|
|
|
|
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
|
|
|
|
const result = await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(result).toBe(successResponse);
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
|
|
serverUrl: 'https://api.example.com',
|
|
resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl,
|
|
scope: mockWWWAuthenticateParams.scope,
|
|
fetchFn: mockFetch
|
|
});
|
|
|
|
// Verify the retry used the new token
|
|
const retryCallArgs = mockFetch.mock.calls[1];
|
|
const retryHeaders = retryCallArgs[1]?.headers as Headers;
|
|
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
|
|
});
|
|
|
|
it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
|
|
mockProvider.tokens
|
|
.mockResolvedValueOnce({
|
|
access_token: 'old-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
})
|
|
.mockResolvedValueOnce({
|
|
access_token: 'new-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
const unauthorizedResponse = new Response('Unauthorized', {
|
|
status: 401,
|
|
headers: { 'www-authenticate': 'Bearer realm="oauth"' }
|
|
});
|
|
const successResponse = new Response('success', { status: 200 });
|
|
|
|
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
|
|
|
|
const mockWWWAuthenticateParams = {
|
|
resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'),
|
|
scope: 'read'
|
|
};
|
|
mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams);
|
|
mockAuth.mockResolvedValue('AUTHORIZED');
|
|
|
|
// Test without baseUrl - should extract from request URL
|
|
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
|
|
|
|
const result = await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(result).toBe(successResponse);
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
|
|
serverUrl: 'https://api.example.com', // Should be extracted from request URL
|
|
resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl,
|
|
scope: mockWWWAuthenticateParams.scope,
|
|
fetchFn: mockFetch
|
|
});
|
|
|
|
// Verify the retry used the new token
|
|
const retryCallArgs = mockFetch.mock.calls[1];
|
|
const retryHeaders = retryCallArgs[1]?.headers as Headers;
|
|
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
|
|
});
|
|
|
|
it('should throw UnauthorizedError when auth returns REDIRECT (without baseUrl)', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
|
|
mockExtractWWWAuthenticateParams.mockReturnValue({});
|
|
mockAuth.mockResolvedValue('REDIRECT');
|
|
|
|
// Test without baseUrl
|
|
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
|
|
|
|
await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow(
|
|
'Authentication requires user authorization - redirect initiated'
|
|
);
|
|
});
|
|
|
|
it('should throw UnauthorizedError when auth fails', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
|
|
mockExtractWWWAuthenticateParams.mockReturnValue({});
|
|
mockAuth.mockRejectedValue(new Error('Network error'));
|
|
|
|
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
|
|
|
|
await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow('Failed to re-authenticate: Network error');
|
|
});
|
|
|
|
it('should handle persistent 401 responses after auth', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
// Always return 401
|
|
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
|
|
mockExtractWWWAuthenticateParams.mockReturnValue({});
|
|
mockAuth.mockResolvedValue('AUTHORIZED');
|
|
|
|
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
|
|
|
|
await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow(
|
|
'Authentication failed for https://api.example.com/data'
|
|
);
|
|
|
|
// Should have made initial request + 1 retry after auth = 2 total
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
expect(mockAuth).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should preserve original request method and body', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(new Response('success', { status: 200 }));
|
|
|
|
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
|
|
|
|
const requestBody = JSON.stringify({ data: 'test' });
|
|
await enhancedFetch('https://api.example.com/data', {
|
|
method: 'POST',
|
|
body: requestBody,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://api.example.com/data',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: requestBody,
|
|
headers: expect.any(Headers)
|
|
})
|
|
);
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Content-Type')).toBe('application/json');
|
|
expect(headers.get('Authorization')).toBe('Bearer test-token');
|
|
});
|
|
|
|
it('should handle non-401 errors normally', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
const serverErrorResponse = new Response('Server Error', { status: 500 });
|
|
mockFetch.mockResolvedValue(serverErrorResponse);
|
|
|
|
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
|
|
|
|
const result = await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(result).toBe(serverErrorResponse);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
expect(mockAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle URL object as input (without baseUrl)', async () => {
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(new Response('success', { status: 200 }));
|
|
|
|
// Test URL object without baseUrl - should extract origin from URL object
|
|
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
|
|
|
|
await enhancedFetch(new URL('https://api.example.com/data'));
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(URL),
|
|
expect.objectContaining({
|
|
headers: expect.any(Headers)
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle URL object in auth retry (without baseUrl)', async () => {
|
|
mockProvider.tokens
|
|
.mockResolvedValueOnce({
|
|
access_token: 'old-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
})
|
|
.mockResolvedValueOnce({
|
|
access_token: 'new-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
const unauthorizedResponse = new Response('Unauthorized', { status: 401 });
|
|
const successResponse = new Response('success', { status: 200 });
|
|
|
|
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
|
|
|
|
mockExtractWWWAuthenticateParams.mockReturnValue({});
|
|
mockAuth.mockResolvedValue('AUTHORIZED');
|
|
|
|
const enhancedFetch = withOAuth(mockProvider)(mockFetch);
|
|
|
|
const result = await enhancedFetch(new URL('https://api.example.com/data'));
|
|
|
|
expect(result).toBe(successResponse);
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
|
|
serverUrl: 'https://api.example.com', // Should extract origin from URL object
|
|
resourceMetadataUrl: undefined,
|
|
fetchFn: mockFetch
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('withLogging', () => {
|
|
let mockFetch: MockedFunction<FetchLike>;
|
|
let mockLogger: MockedFunction<
|
|
(input: {
|
|
method: string;
|
|
url: string | URL;
|
|
status: number;
|
|
statusText: string;
|
|
duration: number;
|
|
requestHeaders?: Headers;
|
|
responseHeaders?: Headers;
|
|
error?: Error;
|
|
}) => void
|
|
>;
|
|
let consoleErrorSpy: MockInstance;
|
|
let consoleLogSpy: MockInstance;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
mockFetch = vi.fn();
|
|
mockLogger = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleErrorSpy.mockRestore();
|
|
consoleLogSpy.mockRestore();
|
|
});
|
|
|
|
it('should log successful requests with default logger', async () => {
|
|
const response = new Response('success', { status: 200, statusText: 'OK' });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const enhancedFetch = withLogging()(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
expect.stringMatching(/HTTP GET https:\/\/api\.example\.com\/data 200 OK \(\d+\.\d+ms\)/)
|
|
);
|
|
});
|
|
|
|
it('should log error responses with default logger', async () => {
|
|
const response = new Response('Not Found', {
|
|
status: 404,
|
|
statusText: 'Not Found'
|
|
});
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const enhancedFetch = withLogging()(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringMatching(/HTTP GET https:\/\/api\.example\.com\/data 404 Not Found \(\d+\.\d+ms\)/)
|
|
);
|
|
});
|
|
|
|
it('should log network errors with default logger', async () => {
|
|
const networkError = new Error('Network connection failed');
|
|
mockFetch.mockRejectedValue(networkError);
|
|
|
|
const enhancedFetch = withLogging()(mockFetch);
|
|
|
|
await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow('Network connection failed');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringMatching(/HTTP GET https:\/\/api\.example\.com\/data failed: Network connection failed \(\d+\.\d+ms\)/)
|
|
);
|
|
});
|
|
|
|
it('should use custom logger when provided', async () => {
|
|
const response = new Response('success', { status: 200, statusText: 'OK' });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const enhancedFetch = withLogging({ logger: mockLogger })(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data', { method: 'POST' });
|
|
|
|
expect(mockLogger).toHaveBeenCalledWith({
|
|
method: 'POST',
|
|
url: 'https://api.example.com/data',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
duration: expect.any(Number),
|
|
requestHeaders: undefined,
|
|
responseHeaders: undefined
|
|
});
|
|
|
|
expect(consoleLogSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should include request headers when configured', async () => {
|
|
const response = new Response('success', { status: 200, statusText: 'OK' });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const enhancedFetch = withLogging({
|
|
logger: mockLogger,
|
|
includeRequestHeaders: true
|
|
})(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data', {
|
|
headers: {
|
|
Authorization: 'Bearer token',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
expect(mockLogger).toHaveBeenCalledWith({
|
|
method: 'GET',
|
|
url: 'https://api.example.com/data',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
duration: expect.any(Number),
|
|
requestHeaders: expect.any(Headers),
|
|
responseHeaders: undefined
|
|
});
|
|
|
|
const logCall = mockLogger.mock.calls[0][0];
|
|
expect(logCall.requestHeaders?.get('Authorization')).toBe('Bearer token');
|
|
expect(logCall.requestHeaders?.get('Content-Type')).toBe('application/json');
|
|
});
|
|
|
|
it('should include response headers when configured', async () => {
|
|
const response = new Response('success', {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-cache'
|
|
}
|
|
});
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const enhancedFetch = withLogging({
|
|
logger: mockLogger,
|
|
includeResponseHeaders: true
|
|
})(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
const logCall = mockLogger.mock.calls[0][0];
|
|
expect(logCall.responseHeaders?.get('Content-Type')).toBe('application/json');
|
|
expect(logCall.responseHeaders?.get('Cache-Control')).toBe('no-cache');
|
|
});
|
|
|
|
it('should respect statusLevel option', async () => {
|
|
const successResponse = new Response('success', {
|
|
status: 200,
|
|
statusText: 'OK'
|
|
});
|
|
const errorResponse = new Response('Server Error', {
|
|
status: 500,
|
|
statusText: 'Internal Server Error'
|
|
});
|
|
|
|
mockFetch.mockResolvedValueOnce(successResponse).mockResolvedValueOnce(errorResponse);
|
|
|
|
const enhancedFetch = withLogging({
|
|
logger: mockLogger,
|
|
statusLevel: 400
|
|
})(mockFetch);
|
|
|
|
// 200 response should not be logged (below statusLevel 400)
|
|
await enhancedFetch('https://api.example.com/success');
|
|
expect(mockLogger).not.toHaveBeenCalled();
|
|
|
|
// 500 response should be logged (above statusLevel 400)
|
|
await enhancedFetch('https://api.example.com/error');
|
|
expect(mockLogger).toHaveBeenCalledWith({
|
|
method: 'GET',
|
|
url: 'https://api.example.com/error',
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
duration: expect.any(Number),
|
|
requestHeaders: undefined,
|
|
responseHeaders: undefined
|
|
});
|
|
});
|
|
|
|
it('should always log network errors regardless of statusLevel', async () => {
|
|
const networkError = new Error('Connection timeout');
|
|
mockFetch.mockRejectedValue(networkError);
|
|
|
|
const enhancedFetch = withLogging({
|
|
logger: mockLogger,
|
|
statusLevel: 500 // Very high log level
|
|
})(mockFetch);
|
|
|
|
await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow('Connection timeout');
|
|
|
|
expect(mockLogger).toHaveBeenCalledWith({
|
|
method: 'GET',
|
|
url: 'https://api.example.com/data',
|
|
status: 0,
|
|
statusText: 'Network Error',
|
|
duration: expect.any(Number),
|
|
requestHeaders: undefined,
|
|
error: networkError
|
|
});
|
|
});
|
|
|
|
it('should include headers in default logger message when configured', async () => {
|
|
const response = new Response('success', {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const enhancedFetch = withLogging({
|
|
includeRequestHeaders: true,
|
|
includeResponseHeaders: true
|
|
})(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data', {
|
|
headers: { Authorization: 'Bearer token' }
|
|
});
|
|
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Request Headers: {authorization: Bearer token}'));
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Response Headers: {content-type: application/json}'));
|
|
});
|
|
|
|
it('should measure request duration accurately', async () => {
|
|
// Mock a slow response
|
|
const response = new Response('success', { status: 200 });
|
|
mockFetch.mockImplementation(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
return response;
|
|
});
|
|
|
|
const enhancedFetch = withLogging({ logger: mockLogger })(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
const logCall = mockLogger.mock.calls[0][0];
|
|
expect(logCall.duration).toBeGreaterThanOrEqual(90); // Allow some margin for timing
|
|
});
|
|
});
|
|
|
|
describe('applyMiddleware', () => {
|
|
let mockFetch: MockedFunction<FetchLike>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockFetch = vi.fn();
|
|
});
|
|
|
|
it('should compose no middleware correctly', () => {
|
|
const response = new Response('success', { status: 200 });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const composedFetch = applyMiddlewares()(mockFetch);
|
|
|
|
expect(composedFetch).toBe(mockFetch);
|
|
});
|
|
|
|
it('should compose single middleware correctly', async () => {
|
|
const response = new Response('success', { status: 200 });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
// Create a middleware that adds a header
|
|
const middleware1 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('X-Middleware-1', 'applied');
|
|
return next(input, { ...init, headers });
|
|
};
|
|
|
|
const composedFetch = applyMiddlewares(middleware1)(mockFetch);
|
|
|
|
await composedFetch('https://api.example.com/data');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://api.example.com/data',
|
|
expect.objectContaining({
|
|
headers: expect.any(Headers)
|
|
})
|
|
);
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('X-Middleware-1')).toBe('applied');
|
|
});
|
|
|
|
it('should compose multiple middleware in order', async () => {
|
|
const response = new Response('success', { status: 200 });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
// Create middleware that add identifying headers
|
|
const middleware1 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('X-Middleware-1', 'applied');
|
|
return next(input, { ...init, headers });
|
|
};
|
|
|
|
const middleware2 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('X-Middleware-2', 'applied');
|
|
return next(input, { ...init, headers });
|
|
};
|
|
|
|
const middleware3 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('X-Middleware-3', 'applied');
|
|
return next(input, { ...init, headers });
|
|
};
|
|
|
|
const composedFetch = applyMiddlewares(middleware1, middleware2, middleware3)(mockFetch);
|
|
|
|
await composedFetch('https://api.example.com/data');
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('X-Middleware-1')).toBe('applied');
|
|
expect(headers.get('X-Middleware-2')).toBe('applied');
|
|
expect(headers.get('X-Middleware-3')).toBe('applied');
|
|
});
|
|
|
|
it('should work with real fetch middleware functions', async () => {
|
|
const response = new Response('success', { status: 200, statusText: 'OK' });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
// Create middleware that add identifying headers
|
|
const oauthMiddleware = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('Authorization', 'Bearer test-token');
|
|
return next(input, { ...init, headers });
|
|
};
|
|
|
|
// Use custom logger to avoid console output
|
|
const mockLogger = vi.fn();
|
|
const composedFetch = applyMiddlewares(oauthMiddleware, withLogging({ logger: mockLogger, statusLevel: 0 }))(mockFetch);
|
|
|
|
await composedFetch('https://api.example.com/data');
|
|
|
|
// Should have both Authorization header and logging
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Authorization')).toBe('Bearer test-token');
|
|
expect(mockLogger).toHaveBeenCalledWith({
|
|
method: 'GET',
|
|
url: 'https://api.example.com/data',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
duration: expect.any(Number),
|
|
requestHeaders: undefined,
|
|
responseHeaders: undefined
|
|
});
|
|
});
|
|
|
|
it('should preserve error propagation through middleware', async () => {
|
|
const errorMiddleware = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => {
|
|
try {
|
|
return await next(input, init);
|
|
} catch (error) {
|
|
// Add context to the error
|
|
throw new Error(`Middleware error: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
};
|
|
|
|
const originalError = new Error('Network failure');
|
|
mockFetch.mockRejectedValue(originalError);
|
|
|
|
const composedFetch = applyMiddlewares(errorMiddleware)(mockFetch);
|
|
|
|
await expect(composedFetch('https://api.example.com/data')).rejects.toThrow('Middleware error: Network failure');
|
|
});
|
|
});
|
|
|
|
describe('Integration Tests', () => {
|
|
let mockProvider: Mocked<OAuthClientProvider>;
|
|
let mockFetch: MockedFunction<FetchLike>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockProvider = {
|
|
get redirectUrl() {
|
|
return 'http://localhost/callback';
|
|
},
|
|
get clientMetadata() {
|
|
return { redirect_uris: ['http://localhost/callback'] };
|
|
},
|
|
tokens: vi.fn(),
|
|
saveTokens: vi.fn(),
|
|
clientInformation: vi.fn(),
|
|
redirectToAuthorization: vi.fn(),
|
|
saveCodeVerifier: vi.fn(),
|
|
codeVerifier: vi.fn(),
|
|
invalidateCredentials: vi.fn()
|
|
};
|
|
|
|
mockFetch = vi.fn();
|
|
});
|
|
|
|
it('should work with SSE transport pattern', async () => {
|
|
// Simulate how SSE transport might use the middleware
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'sse-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
const response = new Response('{"jsonrpc":"2.0","id":1,"result":{}}', {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
// Use custom logger to avoid console output
|
|
const mockLogger = vi.fn();
|
|
const enhancedFetch = applyMiddlewares(
|
|
withOAuth(mockProvider as OAuthClientProvider, 'https://mcp-server.example.com'),
|
|
withLogging({ logger: mockLogger, statusLevel: 400 }) // Only log errors
|
|
)(mockFetch);
|
|
|
|
// Simulate SSE POST request
|
|
await enhancedFetch('https://mcp-server.example.com/endpoint', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method: 'tools/list',
|
|
id: 1
|
|
})
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://mcp-server.example.com/endpoint',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.any(Headers),
|
|
body: expect.any(String)
|
|
})
|
|
);
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Authorization')).toBe('Bearer sse-token');
|
|
expect(headers.get('Content-Type')).toBe('application/json');
|
|
});
|
|
|
|
it('should work with StreamableHTTP transport pattern', async () => {
|
|
// Simulate how StreamableHTTP transport might use the middleware
|
|
mockProvider.tokens.mockResolvedValue({
|
|
access_token: 'streamable-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
const response = new Response(null, {
|
|
status: 202,
|
|
headers: { 'mcp-session-id': 'session-123' }
|
|
});
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
// Use custom logger to avoid console output
|
|
const mockLogger = vi.fn();
|
|
const enhancedFetch = applyMiddlewares(
|
|
withOAuth(mockProvider as OAuthClientProvider, 'https://streamable-server.example.com'),
|
|
withLogging({
|
|
logger: mockLogger,
|
|
includeResponseHeaders: true,
|
|
statusLevel: 0
|
|
})
|
|
)(mockFetch);
|
|
|
|
// Simulate StreamableHTTP initialization request
|
|
await enhancedFetch('https://streamable-server.example.com/mcp', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json, text/event-stream'
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method: 'initialize',
|
|
params: { protocolVersion: '2025-03-26', clientInfo: { name: 'test' } },
|
|
id: 1
|
|
})
|
|
});
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Authorization')).toBe('Bearer streamable-token');
|
|
expect(headers.get('Accept')).toBe('application/json, text/event-stream');
|
|
});
|
|
|
|
it('should handle auth retry in transport-like scenario', async () => {
|
|
mockProvider.tokens
|
|
.mockResolvedValueOnce({
|
|
access_token: 'expired-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
})
|
|
.mockResolvedValueOnce({
|
|
access_token: 'fresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
});
|
|
|
|
const unauthorizedResponse = new Response('{"error":"invalid_token"}', {
|
|
status: 401,
|
|
headers: { 'www-authenticate': 'Bearer realm="mcp"' }
|
|
});
|
|
const successResponse = new Response('{"jsonrpc":"2.0","id":1,"result":{}}', {
|
|
status: 200
|
|
});
|
|
|
|
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
|
|
|
|
mockExtractWWWAuthenticateParams.mockReturnValue({
|
|
resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'),
|
|
scope: 'read'
|
|
});
|
|
mockAuth.mockResolvedValue('AUTHORIZED');
|
|
|
|
// Use custom logger to avoid console output
|
|
const mockLogger = vi.fn();
|
|
const enhancedFetch = applyMiddlewares(
|
|
withOAuth(mockProvider as OAuthClientProvider, 'https://mcp-server.example.com'),
|
|
withLogging({ logger: mockLogger, statusLevel: 0 })
|
|
)(mockFetch);
|
|
|
|
const result = await enhancedFetch('https://mcp-server.example.com/endpoint', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'test', id: 1 })
|
|
});
|
|
|
|
expect(result).toBe(successResponse);
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
|
|
serverUrl: 'https://mcp-server.example.com',
|
|
resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'),
|
|
scope: 'read',
|
|
fetchFn: mockFetch
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createMiddleware', () => {
|
|
let mockFetch: MockedFunction<FetchLike>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockFetch = vi.fn();
|
|
});
|
|
|
|
it('should create middleware with cleaner syntax', async () => {
|
|
const response = new Response('success', { status: 200 });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
const customMiddleware = createMiddleware(async (next, input, init) => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('X-Custom-Header', 'custom-value');
|
|
return next(input, { ...init, headers });
|
|
});
|
|
|
|
const enhancedFetch = customMiddleware(mockFetch);
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://api.example.com/data',
|
|
expect.objectContaining({
|
|
headers: expect.any(Headers)
|
|
})
|
|
);
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('X-Custom-Header')).toBe('custom-value');
|
|
});
|
|
|
|
it('should support conditional middleware logic', async () => {
|
|
const apiResponse = new Response('api response', { status: 200 });
|
|
const publicResponse = new Response('public response', { status: 200 });
|
|
mockFetch.mockResolvedValueOnce(apiResponse).mockResolvedValueOnce(publicResponse);
|
|
|
|
const conditionalMiddleware = createMiddleware(async (next, input, init) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
|
|
if (url.includes('/api/')) {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('X-API-Version', 'v2');
|
|
return next(input, { ...init, headers });
|
|
}
|
|
|
|
return next(input, init);
|
|
});
|
|
|
|
const enhancedFetch = conditionalMiddleware(mockFetch);
|
|
|
|
// Test API route
|
|
await enhancedFetch('https://example.com/api/users');
|
|
let callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('X-API-Version')).toBe('v2');
|
|
|
|
// Test non-API route
|
|
await enhancedFetch('https://example.com/public/page');
|
|
callArgs = mockFetch.mock.calls[1];
|
|
const maybeHeaders = callArgs[1]?.headers as Headers | undefined;
|
|
expect(maybeHeaders?.get('X-API-Version')).toBeUndefined();
|
|
});
|
|
|
|
it('should support short-circuit responses', async () => {
|
|
const customMiddleware = createMiddleware(async (next, input, init) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
|
|
// Short-circuit for specific URL
|
|
if (url.includes('/cached')) {
|
|
return new Response('cached data', { status: 200 });
|
|
}
|
|
|
|
return next(input, init);
|
|
});
|
|
|
|
const enhancedFetch = customMiddleware(mockFetch);
|
|
|
|
// Test cached route (should not call mockFetch)
|
|
const cachedResponse = await enhancedFetch('https://example.com/cached/data');
|
|
expect(await cachedResponse.text()).toBe('cached data');
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
|
|
// Test normal route
|
|
mockFetch.mockResolvedValue(new Response('fresh data', { status: 200 }));
|
|
const normalResponse = await enhancedFetch('https://example.com/normal/data');
|
|
expect(await normalResponse.text()).toBe('fresh data');
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle response transformation', async () => {
|
|
const originalResponse = new Response('{"data": "original"}', {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
mockFetch.mockResolvedValue(originalResponse);
|
|
|
|
const transformMiddleware = createMiddleware(async (next, input, init) => {
|
|
const response = await next(input, init);
|
|
|
|
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
const data = await response.json();
|
|
const transformed = { ...data, timestamp: 123456789 };
|
|
|
|
return new Response(JSON.stringify(transformed), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers
|
|
});
|
|
}
|
|
|
|
return response;
|
|
});
|
|
|
|
const enhancedFetch = transformMiddleware(mockFetch);
|
|
const response = await enhancedFetch('https://api.example.com/data');
|
|
const result = await response.json();
|
|
|
|
expect(result).toEqual({
|
|
data: 'original',
|
|
timestamp: 123456789
|
|
});
|
|
});
|
|
|
|
it('should support error handling and recovery', async () => {
|
|
let attemptCount = 0;
|
|
mockFetch.mockImplementation(async () => {
|
|
attemptCount++;
|
|
if (attemptCount === 1) {
|
|
throw new Error('Network error');
|
|
}
|
|
return new Response('success', { status: 200 });
|
|
});
|
|
|
|
const retryMiddleware = createMiddleware(async (next, input, init) => {
|
|
try {
|
|
return await next(input, init);
|
|
} catch (error) {
|
|
// Retry once on network error
|
|
console.log('Retrying request after error:', error);
|
|
return await next(input, init);
|
|
}
|
|
});
|
|
|
|
const enhancedFetch = retryMiddleware(mockFetch);
|
|
const response = await enhancedFetch('https://api.example.com/data');
|
|
|
|
expect(await response.text()).toBe('success');
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should compose well with other middleware', async () => {
|
|
const response = new Response('success', { status: 200 });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
// Create custom middleware using createMiddleware
|
|
const customAuth = createMiddleware(async (next, input, init) => {
|
|
const headers = new Headers(init?.headers);
|
|
headers.set('Authorization', 'Custom token');
|
|
return next(input, { ...init, headers });
|
|
});
|
|
|
|
const customLogging = createMiddleware(async (next, input, init) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
console.log(`Request to: ${url}`);
|
|
const response = await next(input, init);
|
|
console.log(`Response status: ${response.status}`);
|
|
return response;
|
|
});
|
|
|
|
// Compose with existing middleware
|
|
const enhancedFetch = applyMiddlewares(customAuth, customLogging, withLogging({ statusLevel: 400 }))(mockFetch);
|
|
|
|
await enhancedFetch('https://api.example.com/data');
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
const headers = callArgs[1]?.headers as Headers;
|
|
expect(headers.get('Authorization')).toBe('Custom token');
|
|
});
|
|
|
|
it('should have access to both input types (string and URL)', async () => {
|
|
const response = new Response('success', { status: 200 });
|
|
mockFetch.mockResolvedValue(response);
|
|
|
|
let capturedInputType: string | undefined;
|
|
const inspectMiddleware = createMiddleware(async (next, input, init) => {
|
|
capturedInputType = typeof input === 'string' ? 'string' : 'URL';
|
|
return next(input, init);
|
|
});
|
|
|
|
const enhancedFetch = inspectMiddleware(mockFetch);
|
|
|
|
// Test with string input
|
|
await enhancedFetch('https://api.example.com/data');
|
|
expect(capturedInputType).toBe('string');
|
|
|
|
// Test with URL input
|
|
await enhancedFetch(new URL('https://api.example.com/data'));
|
|
expect(capturedInputType).toBe('URL');
|
|
});
|
|
});
|