Relocate tests under /test (#1220)
This commit is contained in:
parent
7655eeb51c
commit
5a9de1c638
18 changed files with 630 additions and 524 deletions
|
|
@ -1,294 +0,0 @@
|
|||
import { authorizationHandler, AuthorizationHandlerOptions } from './authorize.js';
|
||||
import { OAuthServerProvider, AuthorizationParams } from '../provider.js';
|
||||
import { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js';
|
||||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { AuthInfo } from '../types.js';
|
||||
import { InvalidTokenError } from '../errors.js';
|
||||
|
||||
describe('Authorization Handler', () => {
|
||||
// Mock client data
|
||||
const validClient: OAuthClientInformationFull = {
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
scope: 'profile email'
|
||||
};
|
||||
|
||||
const multiRedirectClient: OAuthClientInformationFull = {
|
||||
client_id: 'multi-redirect-client',
|
||||
client_secret: 'valid-secret',
|
||||
redirect_uris: ['https://example.com/callback1', 'https://example.com/callback2'],
|
||||
scope: 'profile email'
|
||||
};
|
||||
|
||||
// Mock client store
|
||||
const mockClientStore: OAuthRegisteredClientsStore = {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
if (clientId === 'valid-client') {
|
||||
return validClient;
|
||||
} else if (clientId === 'multi-redirect-client') {
|
||||
return multiRedirectClient;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock provider
|
||||
const mockProvider: OAuthServerProvider = {
|
||||
clientsStore: mockClientStore,
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
// Mock implementation - redirects to redirectUri with code and state
|
||||
const redirectUrl = new URL(params.redirectUri);
|
||||
redirectUrl.searchParams.set('code', 'mock_auth_code');
|
||||
if (params.state) {
|
||||
redirectUrl.searchParams.set('state', params.state);
|
||||
}
|
||||
res.redirect(302, redirectUrl.toString());
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(): Promise<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'new_mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(): Promise<void> {
|
||||
// Do nothing in mock
|
||||
}
|
||||
};
|
||||
|
||||
// Setup express app with handler
|
||||
let app: express.Express;
|
||||
let options: AuthorizationHandlerOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
options = { provider: mockProvider };
|
||||
const handler = authorizationHandler(options);
|
||||
app.use('/authorize', handler);
|
||||
});
|
||||
|
||||
describe('HTTP method validation', () => {
|
||||
it('rejects non-GET/POST methods', async () => {
|
||||
const response = await supertest(app).put('/authorize').query({ client_id: 'valid-client' });
|
||||
|
||||
expect(response.status).toBe(405); // Method not allowed response from handler
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client validation', () => {
|
||||
it('requires client_id parameter', async () => {
|
||||
const response = await supertest(app).get('/authorize');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.text).toContain('client_id');
|
||||
});
|
||||
|
||||
it('validates that client exists', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({ client_id: 'nonexistent-client' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redirect URI validation', () => {
|
||||
it('uses the only redirect_uri if client has just one and none provided', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.origin + location.pathname).toBe('https://example.com/callback');
|
||||
});
|
||||
|
||||
it('requires redirect_uri if client has multiple', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'multi-redirect-client',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('validates redirect_uri against client registered URIs', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://malicious.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts valid redirect_uri that client registered with', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.origin + location.pathname).toBe('https://example.com/callback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization request validation', () => {
|
||||
it('requires response_type=code', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'token', // invalid - we only support code flow
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.searchParams.get('error')).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('requires code_challenge parameter', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge_method: 'S256'
|
||||
// Missing code_challenge
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.searchParams.get('error')).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('requires code_challenge_method=S256', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'plain' // Only S256 is supported
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.searchParams.get('error')).toBe('invalid_request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource parameter validation', () => {
|
||||
it('propagates resource parameter', async () => {
|
||||
const mockProviderWithResource = vi.spyOn(mockProvider, 'authorize');
|
||||
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256',
|
||||
resource: 'https://api.example.com/resource'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(mockProviderWithResource).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
expect.objectContaining({
|
||||
resource: new URL('https://api.example.com/resource'),
|
||||
redirectUri: 'https://example.com/callback',
|
||||
codeChallenge: 'challenge123'
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful authorization', () => {
|
||||
it('handles successful authorization with all parameters', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256',
|
||||
scope: 'profile email',
|
||||
state: 'xyz789'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.origin + location.pathname).toBe('https://example.com/callback');
|
||||
expect(location.searchParams.get('code')).toBe('mock_auth_code');
|
||||
expect(location.searchParams.get('state')).toBe('xyz789');
|
||||
});
|
||||
|
||||
it('preserves state parameter in response', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256',
|
||||
state: 'state-value-123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.searchParams.get('state')).toBe('state-value-123');
|
||||
});
|
||||
|
||||
it('handles POST requests the same as GET', async () => {
|
||||
const response = await supertest(app).post('/authorize').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.searchParams.has('code')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { metadataHandler } from './metadata.js';
|
||||
import { OAuthMetadata } from '../../../shared/auth.js';
|
||||
import express from 'express';
|
||||
import supertest from 'supertest';
|
||||
|
||||
describe('Metadata Handler', () => {
|
||||
const exampleMetadata: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
registration_endpoint: 'https://auth.example.com/register',
|
||||
revocation_endpoint: 'https://auth.example.com/revoke',
|
||||
scopes_supported: ['profile', 'email'],
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||
code_challenge_methods_supported: ['S256']
|
||||
};
|
||||
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup express app with metadata handler
|
||||
app = express();
|
||||
app.use('/.well-known/oauth-authorization-server', metadataHandler(exampleMetadata));
|
||||
});
|
||||
|
||||
it('requires GET method', async () => {
|
||||
const response = await supertest(app).post('/.well-known/oauth-authorization-server').send({});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.allow).toBe('GET, OPTIONS');
|
||||
expect(response.body).toEqual({
|
||||
error: 'method_not_allowed',
|
||||
error_description: 'The method POST is not allowed for this endpoint'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the metadata object', async () => {
|
||||
const response = await supertest(app).get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(exampleMetadata);
|
||||
});
|
||||
|
||||
it('includes CORS headers in response', async () => {
|
||||
const response = await supertest(app).get('/.well-known/oauth-authorization-server').set('Origin', 'https://example.com');
|
||||
|
||||
expect(response.header['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
|
||||
it('supports OPTIONS preflight requests', async () => {
|
||||
const response = await supertest(app)
|
||||
.options('/.well-known/oauth-authorization-server')
|
||||
.set('Origin', 'https://example.com')
|
||||
.set('Access-Control-Request-Method', 'GET');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.header['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
|
||||
it('works with minimal metadata', async () => {
|
||||
// Setup a new express app with minimal metadata
|
||||
const minimalApp = express();
|
||||
const minimalMetadata: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata));
|
||||
|
||||
const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(minimalMetadata);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from './register.js';
|
||||
import { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { OAuthClientInformationFull, OAuthClientMetadata } from '../../../shared/auth.js';
|
||||
import express from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { MockInstance } from 'vitest';
|
||||
|
||||
describe('Client Registration Handler', () => {
|
||||
// Mock client store with registration support
|
||||
const mockClientStoreWithRegistration: OAuthRegisteredClientsStore = {
|
||||
async getClient(_clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
|
||||
// Return the client info as-is in the mock
|
||||
return client;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock client store without registration support
|
||||
const mockClientStoreWithoutRegistration: OAuthRegisteredClientsStore = {
|
||||
async getClient(_clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
// No registerClient method
|
||||
};
|
||||
|
||||
describe('Handler creation', () => {
|
||||
it('throws error if client store does not support registration', () => {
|
||||
const options: ClientRegistrationHandlerOptions = {
|
||||
clientsStore: mockClientStoreWithoutRegistration
|
||||
};
|
||||
|
||||
expect(() => clientRegistrationHandler(options)).toThrow('does not support registering clients');
|
||||
});
|
||||
|
||||
it('creates handler if client store supports registration', () => {
|
||||
const options: ClientRegistrationHandlerOptions = {
|
||||
clientsStore: mockClientStoreWithRegistration
|
||||
};
|
||||
|
||||
expect(() => clientRegistrationHandler(options)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request handling', () => {
|
||||
let app: express.Express;
|
||||
let spyRegisterClient: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup express app with registration handler
|
||||
app = express();
|
||||
const options: ClientRegistrationHandlerOptions = {
|
||||
clientsStore: mockClientStoreWithRegistration,
|
||||
clientSecretExpirySeconds: 86400 // 1 day for testing
|
||||
};
|
||||
|
||||
app.use('/register', clientRegistrationHandler(options));
|
||||
|
||||
// Spy on the registerClient method
|
||||
spyRegisterClient = vi.spyOn(mockClientStoreWithRegistration, 'registerClient');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
spyRegisterClient.mockRestore();
|
||||
});
|
||||
|
||||
it('requires POST method', async () => {
|
||||
const response = await supertest(app)
|
||||
.get('/register')
|
||||
.send({
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.allow).toBe('POST');
|
||||
expect(response.body).toEqual({
|
||||
error: 'method_not_allowed',
|
||||
error_description: 'The method GET is not allowed for this endpoint'
|
||||
});
|
||||
expect(spyRegisterClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates required client metadata', async () => {
|
||||
const response = await supertest(app).post('/register').send({
|
||||
// Missing redirect_uris (required)
|
||||
client_name: 'Test Client'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_client_metadata');
|
||||
expect(spyRegisterClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates redirect URIs format', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/register')
|
||||
.send({
|
||||
redirect_uris: ['invalid-url'] // Invalid URL format
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_client_metadata');
|
||||
expect(response.body.error_description).toContain('redirect_uris');
|
||||
expect(spyRegisterClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('successfully registers client with minimal metadata', async () => {
|
||||
const clientMetadata: OAuthClientMetadata = {
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
|
||||
const response = await supertest(app).post('/register').send(clientMetadata);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
// Verify the generated client information
|
||||
expect(response.body.client_id).toBeDefined();
|
||||
expect(response.body.client_secret).toBeDefined();
|
||||
expect(response.body.client_id_issued_at).toBeDefined();
|
||||
expect(response.body.client_secret_expires_at).toBeDefined();
|
||||
expect(response.body.redirect_uris).toEqual(['https://example.com/callback']);
|
||||
|
||||
// Verify client was registered
|
||||
expect(spyRegisterClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sets client_secret to undefined for token_endpoint_auth_method=none', async () => {
|
||||
const clientMetadata: OAuthClientMetadata = {
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
token_endpoint_auth_method: 'none'
|
||||
};
|
||||
|
||||
const response = await supertest(app).post('/register').send(clientMetadata);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.client_secret).toBeUndefined();
|
||||
expect(response.body.client_secret_expires_at).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets client_secret_expires_at for public clients only', async () => {
|
||||
// Test for public client (token_endpoint_auth_method not 'none')
|
||||
const publicClientMetadata: OAuthClientMetadata = {
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
token_endpoint_auth_method: 'client_secret_basic'
|
||||
};
|
||||
|
||||
const publicResponse = await supertest(app).post('/register').send(publicClientMetadata);
|
||||
|
||||
expect(publicResponse.status).toBe(201);
|
||||
expect(publicResponse.body.client_secret).toBeDefined();
|
||||
expect(publicResponse.body.client_secret_expires_at).toBeDefined();
|
||||
|
||||
// Test for non-public client (token_endpoint_auth_method is 'none')
|
||||
const nonPublicClientMetadata: OAuthClientMetadata = {
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
token_endpoint_auth_method: 'none'
|
||||
};
|
||||
|
||||
const nonPublicResponse = await supertest(app).post('/register').send(nonPublicClientMetadata);
|
||||
|
||||
expect(nonPublicResponse.status).toBe(201);
|
||||
expect(nonPublicResponse.body.client_secret).toBeUndefined();
|
||||
expect(nonPublicResponse.body.client_secret_expires_at).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets expiry based on clientSecretExpirySeconds', async () => {
|
||||
// Create handler with custom expiry time
|
||||
const customApp = express();
|
||||
const options: ClientRegistrationHandlerOptions = {
|
||||
clientsStore: mockClientStoreWithRegistration,
|
||||
clientSecretExpirySeconds: 3600 // 1 hour
|
||||
};
|
||||
|
||||
customApp.use('/register', clientRegistrationHandler(options));
|
||||
|
||||
const response = await supertest(customApp)
|
||||
.post('/register')
|
||||
.send({
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
// Verify the expiration time (~1 hour from now)
|
||||
const issuedAt = response.body.client_id_issued_at;
|
||||
const expiresAt = response.body.client_secret_expires_at;
|
||||
expect(expiresAt - issuedAt).toBe(3600);
|
||||
});
|
||||
|
||||
it('sets no expiry when clientSecretExpirySeconds=0', async () => {
|
||||
// Create handler with no expiry
|
||||
const customApp = express();
|
||||
const options: ClientRegistrationHandlerOptions = {
|
||||
clientsStore: mockClientStoreWithRegistration,
|
||||
clientSecretExpirySeconds: 0 // No expiry
|
||||
};
|
||||
|
||||
customApp.use('/register', clientRegistrationHandler(options));
|
||||
|
||||
const response = await supertest(customApp)
|
||||
.post('/register')
|
||||
.send({
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.client_secret_expires_at).toBe(0);
|
||||
});
|
||||
|
||||
it('sets no client_id when clientIdGeneration=false', async () => {
|
||||
// Create handler with no expiry
|
||||
const customApp = express();
|
||||
const options: ClientRegistrationHandlerOptions = {
|
||||
clientsStore: mockClientStoreWithRegistration,
|
||||
clientIdGeneration: false
|
||||
};
|
||||
|
||||
customApp.use('/register', clientRegistrationHandler(options));
|
||||
|
||||
const response = await supertest(customApp)
|
||||
.post('/register')
|
||||
.send({
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.client_id).toBeUndefined();
|
||||
expect(response.body.client_id_issued_at).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles client with all metadata fields', async () => {
|
||||
const fullClientMetadata: OAuthClientMetadata = {
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
client_name: 'Test Client',
|
||||
client_uri: 'https://example.com',
|
||||
logo_uri: 'https://example.com/logo.png',
|
||||
scope: 'profile email',
|
||||
contacts: ['dev@example.com'],
|
||||
tos_uri: 'https://example.com/tos',
|
||||
policy_uri: 'https://example.com/privacy',
|
||||
jwks_uri: 'https://example.com/jwks',
|
||||
software_id: 'test-software',
|
||||
software_version: '1.0.0'
|
||||
};
|
||||
|
||||
const response = await supertest(app).post('/register').send(fullClientMetadata);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
// Verify all metadata was preserved
|
||||
Object.entries(fullClientMetadata).forEach(([key, value]) => {
|
||||
expect(response.body[key]).toEqual(value);
|
||||
});
|
||||
});
|
||||
|
||||
it('includes CORS headers in response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/register')
|
||||
.set('Origin', 'https://example.com')
|
||||
.send({
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
});
|
||||
|
||||
expect(response.header['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
import { revocationHandler, RevocationHandlerOptions } from './revoke.js';
|
||||
import { OAuthServerProvider, AuthorizationParams } from '../provider.js';
|
||||
import { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js';
|
||||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { AuthInfo } from '../types.js';
|
||||
import { InvalidTokenError } from '../errors.js';
|
||||
import { MockInstance } from 'vitest';
|
||||
|
||||
describe('Revocation Handler', () => {
|
||||
// Mock client data
|
||||
const validClient: OAuthClientInformationFull = {
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
|
||||
// Mock client store
|
||||
const mockClientStore: OAuthRegisteredClientsStore = {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
if (clientId === 'valid-client') {
|
||||
return validClient;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock provider with revocation capability
|
||||
const mockProviderWithRevocation: OAuthServerProvider = {
|
||||
clientsStore: mockClientStore,
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
res.redirect('https://example.com/callback?code=mock_auth_code');
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(): Promise<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'new_mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
// Success - do nothing in mock
|
||||
}
|
||||
};
|
||||
|
||||
// Mock provider without revocation capability
|
||||
const mockProviderWithoutRevocation: OAuthServerProvider = {
|
||||
clientsStore: mockClientStore,
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
res.redirect('https://example.com/callback?code=mock_auth_code');
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(): Promise<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'new_mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
}
|
||||
|
||||
// No revokeToken method
|
||||
};
|
||||
|
||||
describe('Handler creation', () => {
|
||||
it('throws error if provider does not support token revocation', () => {
|
||||
const options: RevocationHandlerOptions = { provider: mockProviderWithoutRevocation };
|
||||
expect(() => revocationHandler(options)).toThrow('does not support revoking tokens');
|
||||
});
|
||||
|
||||
it('creates handler if provider supports token revocation', () => {
|
||||
const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation };
|
||||
expect(() => revocationHandler(options)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request handling', () => {
|
||||
let app: express.Express;
|
||||
let spyRevokeToken: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup express app with revocation handler
|
||||
app = express();
|
||||
const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation };
|
||||
app.use('/revoke', revocationHandler(options));
|
||||
|
||||
// Spy on the revokeToken method
|
||||
spyRevokeToken = vi.spyOn(mockProviderWithRevocation, 'revokeToken');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
spyRevokeToken.mockRestore();
|
||||
});
|
||||
|
||||
it('requires POST method', async () => {
|
||||
const response = await supertest(app).get('/revoke').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
token: 'token_to_revoke'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.allow).toBe('POST');
|
||||
expect(response.body).toEqual({
|
||||
error: 'method_not_allowed',
|
||||
error_description: 'The method GET is not allowed for this endpoint'
|
||||
});
|
||||
expect(spyRevokeToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires token parameter', async () => {
|
||||
const response = await supertest(app).post('/revoke').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret'
|
||||
// Missing token
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_request');
|
||||
expect(spyRevokeToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authenticates client before revoking token', async () => {
|
||||
const response = await supertest(app).post('/revoke').type('form').send({
|
||||
client_id: 'invalid-client',
|
||||
client_secret: 'wrong-secret',
|
||||
token: 'token_to_revoke'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_client');
|
||||
expect(spyRevokeToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('successfully revokes token', async () => {
|
||||
const response = await supertest(app).post('/revoke').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
token: 'token_to_revoke'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({}); // Empty response on success
|
||||
expect(spyRevokeToken).toHaveBeenCalledTimes(1);
|
||||
expect(spyRevokeToken).toHaveBeenCalledWith(validClient, {
|
||||
token: 'token_to_revoke'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts optional token_type_hint', async () => {
|
||||
const response = await supertest(app).post('/revoke').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
token: 'token_to_revoke',
|
||||
token_type_hint: 'refresh_token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(spyRevokeToken).toHaveBeenCalledWith(validClient, {
|
||||
token: 'token_to_revoke',
|
||||
token_type_hint: 'refresh_token'
|
||||
});
|
||||
});
|
||||
|
||||
it('includes CORS headers in response', async () => {
|
||||
const response = await supertest(app).post('/revoke').type('form').set('Origin', 'https://example.com').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
token: 'token_to_revoke'
|
||||
});
|
||||
|
||||
expect(response.header['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
import { tokenHandler, TokenHandlerOptions } from './token.js';
|
||||
import { OAuthServerProvider, AuthorizationParams } from '../provider.js';
|
||||
import { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js';
|
||||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import * as pkceChallenge from 'pkce-challenge';
|
||||
import { InvalidGrantError, InvalidTokenError } from '../errors.js';
|
||||
import { AuthInfo } from '../types.js';
|
||||
import { ProxyOAuthServerProvider } from '../providers/proxyProvider.js';
|
||||
import { type Mock } from 'vitest';
|
||||
|
||||
// Mock pkce-challenge
|
||||
vi.mock('pkce-challenge', () => ({
|
||||
verifyChallenge: vi.fn().mockImplementation(async (verifier, challenge) => {
|
||||
return verifier === 'valid_verifier' && challenge === 'mock_challenge';
|
||||
})
|
||||
}));
|
||||
|
||||
const mockTokens = {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
|
||||
const mockTokensWithIdToken = {
|
||||
...mockTokens,
|
||||
id_token: 'mock_id_token'
|
||||
};
|
||||
|
||||
describe('Token Handler', () => {
|
||||
// Mock client data
|
||||
const validClient: OAuthClientInformationFull = {
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
|
||||
// Mock client store
|
||||
const mockClientStore: OAuthRegisteredClientsStore = {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
if (clientId === 'valid-client') {
|
||||
return validClient;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock provider
|
||||
let mockProvider: OAuthServerProvider;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh mocks for each test
|
||||
mockProvider = {
|
||||
clientsStore: mockClientStore,
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
res.redirect('https://example.com/callback?code=mock_auth_code');
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
|
||||
if (authorizationCode === 'valid_code') {
|
||||
return 'mock_challenge';
|
||||
} else if (authorizationCode === 'expired_code') {
|
||||
throw new InvalidGrantError('The authorization code has expired');
|
||||
}
|
||||
throw new InvalidGrantError('The authorization code is invalid');
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
|
||||
if (authorizationCode === 'valid_code') {
|
||||
return mockTokens;
|
||||
}
|
||||
throw new InvalidGrantError('The authorization code is invalid or has expired');
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
|
||||
if (refreshToken === 'valid_refresh_token') {
|
||||
const response: OAuthTokens = {
|
||||
access_token: 'new_mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
|
||||
if (scopes) {
|
||||
response.scope = scopes.join(' ');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
throw new InvalidGrantError('The refresh token is invalid or has expired');
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
// Do nothing in mock
|
||||
}
|
||||
};
|
||||
|
||||
// Mock PKCE verification
|
||||
(pkceChallenge.verifyChallenge as Mock).mockImplementation(async (verifier: string, challenge: string) => {
|
||||
return verifier === 'valid_verifier' && challenge === 'mock_challenge';
|
||||
});
|
||||
|
||||
// Setup express app with token handler
|
||||
app = express();
|
||||
const options: TokenHandlerOptions = { provider: mockProvider };
|
||||
app.use('/token', tokenHandler(options));
|
||||
});
|
||||
|
||||
describe('Basic request validation', () => {
|
||||
it('requires POST method', async () => {
|
||||
const response = await supertest(app).get('/token').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.allow).toBe('POST');
|
||||
expect(response.body).toEqual({
|
||||
error: 'method_not_allowed',
|
||||
error_description: 'The method GET is not allowed for this endpoint'
|
||||
});
|
||||
});
|
||||
|
||||
it('requires grant_type parameter', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret'
|
||||
// Missing grant_type
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('rejects unsupported grant types', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'password' // Unsupported grant type
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('unsupported_grant_type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client authentication', () => {
|
||||
it('requires valid client credentials', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'invalid-client',
|
||||
client_secret: 'wrong-secret',
|
||||
grant_type: 'authorization_code'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
it('accepts valid client credentials', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization code grant', () => {
|
||||
it('requires code parameter', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
// Missing code
|
||||
code_verifier: 'valid_verifier'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('requires code_verifier parameter', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code'
|
||||
// Missing code_verifier
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('verifies code_verifier against challenge', async () => {
|
||||
// Setup invalid verifier
|
||||
(pkceChallenge.verifyChallenge as Mock).mockResolvedValueOnce(false);
|
||||
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'invalid_verifier'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_grant');
|
||||
expect(response.body.error_description).toContain('code_verifier');
|
||||
});
|
||||
|
||||
it('rejects expired or invalid authorization codes', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'expired_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_grant');
|
||||
});
|
||||
|
||||
it('returns tokens for valid code exchange', async () => {
|
||||
const mockExchangeCode = vi.spyOn(mockProvider, 'exchangeAuthorizationCode');
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
resource: 'https://api.example.com/resource',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.access_token).toBe('mock_access_token');
|
||||
expect(response.body.token_type).toBe('bearer');
|
||||
expect(response.body.expires_in).toBe(3600);
|
||||
expect(response.body.refresh_token).toBe('mock_refresh_token');
|
||||
expect(mockExchangeCode).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
'valid_code',
|
||||
undefined, // code_verifier is undefined after PKCE validation
|
||||
undefined, // redirect_uri
|
||||
new URL('https://api.example.com/resource') // resource parameter
|
||||
);
|
||||
});
|
||||
|
||||
it('returns id token in code exchange if provided', async () => {
|
||||
mockProvider.exchangeAuthorizationCode = async (
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string
|
||||
): Promise<OAuthTokens> => {
|
||||
if (authorizationCode === 'valid_code') {
|
||||
return mockTokensWithIdToken;
|
||||
}
|
||||
throw new InvalidGrantError('The authorization code is invalid or has expired');
|
||||
};
|
||||
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id_token).toBe('mock_id_token');
|
||||
});
|
||||
|
||||
it('passes through code verifier when using proxy provider', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
try {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokens)
|
||||
});
|
||||
|
||||
const proxyProvider = new ProxyOAuthServerProvider({
|
||||
endpoints: {
|
||||
authorizationUrl: 'https://example.com/authorize',
|
||||
tokenUrl: 'https://example.com/token'
|
||||
},
|
||||
verifyAccessToken: async token => ({
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
}),
|
||||
getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined)
|
||||
});
|
||||
|
||||
const proxyApp = express();
|
||||
const options: TokenHandlerOptions = { provider: proxyProvider };
|
||||
proxyApp.use('/token', tokenHandler(options));
|
||||
|
||||
const response = await supertest(proxyApp).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'any_verifier',
|
||||
redirect_uri: 'https://example.com/callback'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.access_token).toBe('mock_access_token');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining('code_verifier=any_verifier')
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through redirect_uri when using proxy provider', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
try {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokens)
|
||||
});
|
||||
|
||||
const proxyProvider = new ProxyOAuthServerProvider({
|
||||
endpoints: {
|
||||
authorizationUrl: 'https://example.com/authorize',
|
||||
tokenUrl: 'https://example.com/token'
|
||||
},
|
||||
verifyAccessToken: async token => ({
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
}),
|
||||
getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined)
|
||||
});
|
||||
|
||||
const proxyApp = express();
|
||||
const options: TokenHandlerOptions = { provider: proxyProvider };
|
||||
proxyApp.use('/token', tokenHandler(options));
|
||||
|
||||
const redirectUri = 'https://example.com/callback';
|
||||
const response = await supertest(proxyApp).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'any_verifier',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.access_token).toBe('mock_access_token');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh token grant', () => {
|
||||
it('requires refresh_token parameter', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'refresh_token'
|
||||
// Missing refresh_token
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('rejects invalid refresh tokens', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'invalid_refresh_token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_grant');
|
||||
});
|
||||
|
||||
it('returns new tokens for valid refresh token', async () => {
|
||||
const mockExchangeRefresh = vi.spyOn(mockProvider, 'exchangeRefreshToken');
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
resource: 'https://api.example.com/resource',
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'valid_refresh_token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.access_token).toBe('new_mock_access_token');
|
||||
expect(response.body.token_type).toBe('bearer');
|
||||
expect(response.body.expires_in).toBe(3600);
|
||||
expect(response.body.refresh_token).toBe('new_mock_refresh_token');
|
||||
expect(mockExchangeRefresh).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
'valid_refresh_token',
|
||||
undefined, // scopes
|
||||
new URL('https://api.example.com/resource') // resource parameter
|
||||
);
|
||||
});
|
||||
|
||||
it('respects requested scopes on refresh', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'valid_refresh_token',
|
||||
scope: 'profile email'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.scope).toBe('profile email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORS support', () => {
|
||||
it('includes CORS headers in response', async () => {
|
||||
const response = await supertest(app).post('/token').type('form').set('Origin', 'https://example.com').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
});
|
||||
|
||||
expect(response.header['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { allowedMethods } from './allowedMethods.js';
|
||||
import express, { Request, Response } from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
describe('allowedMethods', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
|
||||
// Set up a test router with a GET handler and 405 middleware
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/test', (req, res) => {
|
||||
res.status(200).send('GET success');
|
||||
});
|
||||
|
||||
// Add method not allowed middleware for all other methods
|
||||
router.all('/test', allowedMethods(['GET']));
|
||||
|
||||
app.use(router);
|
||||
});
|
||||
|
||||
test('allows specified HTTP method', async () => {
|
||||
const response = await request(app).get('/test');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('GET success');
|
||||
});
|
||||
|
||||
test('returns 405 for unspecified HTTP methods', async () => {
|
||||
const methods = ['post', 'put', 'delete', 'patch'];
|
||||
|
||||
for (const method of methods) {
|
||||
// @ts-expect-error - dynamic method call
|
||||
const response = await request(app)[method]('/test');
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.body).toEqual({
|
||||
error: 'method_not_allowed',
|
||||
error_description: `The method ${method.toUpperCase()} is not allowed for this endpoint`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('includes Allow header with specified methods', async () => {
|
||||
const response = await request(app).post('/test');
|
||||
expect(response.headers.allow).toBe('GET');
|
||||
});
|
||||
|
||||
test('works with multiple allowed methods', async () => {
|
||||
const multiMethodApp = express();
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/multi', (req: Request, res: Response) => {
|
||||
res.status(200).send('GET');
|
||||
});
|
||||
router.post('/multi', (req: Request, res: Response) => {
|
||||
res.status(200).send('POST');
|
||||
});
|
||||
router.all('/multi', allowedMethods(['GET', 'POST']));
|
||||
|
||||
multiMethodApp.use(router);
|
||||
|
||||
// Allowed methods should work
|
||||
const getResponse = await request(multiMethodApp).get('/multi');
|
||||
expect(getResponse.status).toBe(200);
|
||||
|
||||
const postResponse = await request(multiMethodApp).post('/multi');
|
||||
expect(postResponse.status).toBe(200);
|
||||
|
||||
// Unallowed methods should return 405
|
||||
const putResponse = await request(multiMethodApp).put('/multi');
|
||||
expect(putResponse.status).toBe(405);
|
||||
expect(putResponse.headers.allow).toBe('GET, POST');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,504 +0,0 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { Mock } from 'vitest';
|
||||
import { requireBearerAuth } from './bearerAuth.js';
|
||||
import { AuthInfo } from '../types.js';
|
||||
import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from '../errors.js';
|
||||
import { OAuthTokenVerifier } from '../provider.js';
|
||||
|
||||
// Mock verifier
|
||||
const mockVerifyAccessToken = vi.fn();
|
||||
const mockVerifier: OAuthTokenVerifier = {
|
||||
verifyAccessToken: mockVerifyAccessToken
|
||||
};
|
||||
|
||||
describe('requireBearerAuth middleware', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let nextFunction: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
headers: {}
|
||||
};
|
||||
mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
set: vi.fn().mockReturnThis()
|
||||
};
|
||||
nextFunction = vi.fn();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call next when token is valid', async () => {
|
||||
const validAuthInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockRequest.auth).toEqual(validAuthInfo);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[100], // Token expired 100 seconds ago
|
||||
[0] // Token expires at the same time as now
|
||||
])('should reject expired tokens (expired %s seconds ago)', async (expiredSecondsAgo: number) => {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo;
|
||||
const expiredAuthInfo: AuthInfo = {
|
||||
token: 'expired-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer expired-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined], // Token has no expiration time
|
||||
[NaN] // Token has no expiration time
|
||||
])('should reject tokens with no expiration time (expiresAt: %s)', async (expiresAt: number | undefined) => {
|
||||
const noExpirationAuthInfo: AuthInfo = {
|
||||
token: 'no-expiration-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer expired-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'invalid_token', error_description: 'Token has no expiration time' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept non-expired tokens', async () => {
|
||||
const nonExpiredAuthInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(nonExpiredAuthInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockRequest.auth).toEqual(nonExpiredAuthInfo);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require specific scopes when configured', async () => {
|
||||
const authInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read']
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(authInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ['read', 'write']
|
||||
});
|
||||
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"'));
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'insufficient_scope', error_description: 'Insufficient scope' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept token with all required scopes', async () => {
|
||||
const authInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read', 'write', 'admin'],
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(authInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ['read', 'write']
|
||||
});
|
||||
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockRequest.auth).toEqual(authInfo);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when no Authorization header is present', async () => {
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'invalid_token', error_description: 'Missing Authorization header' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when Authorization header format is invalid', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'InvalidFormat'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'invalid_token',
|
||||
error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'"
|
||||
})
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when token verification fails with InvalidTokenError', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer invalid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('invalid-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'invalid_token', error_description: 'Token expired' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when access token has insufficient scopes', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: read, write'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"'));
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'insufficient_scope', error_description: 'Required scopes: read, write' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 when a ServerError occurs', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'server_error', error_description: 'Internal server issue' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for generic OAuthError', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new CustomOAuthError('custom_error', 'Some OAuth error'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'custom_error', error_description: 'Some OAuth error' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 when unexpected error occurs', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new Error('Unexpected error'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: 'server_error', error_description: 'Internal Server Error' })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with requiredScopes in WWW-Authenticate header', () => {
|
||||
it('should include scope in WWW-Authenticate header for 401 responses when requiredScopes is provided', async () => {
|
||||
mockRequest.headers = {};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ['read', 'write']
|
||||
});
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer error="invalid_token", error_description="Missing Authorization header", scope="read write"'
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include scope in WWW-Authenticate header for 403 insufficient scope responses', async () => {
|
||||
const authInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read']
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(authInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ['read', 'write']
|
||||
});
|
||||
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write"'
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include both scope and resource_metadata in WWW-Authenticate header when both are provided', async () => {
|
||||
mockRequest.headers = {};
|
||||
|
||||
const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource';
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ['admin'],
|
||||
resourceMetadataUrl
|
||||
});
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
`Bearer error="invalid_token", error_description="Missing Authorization header", scope="admin", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with resourceMetadataUrl', () => {
|
||||
const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource';
|
||||
|
||||
it('should include resource_metadata in WWW-Authenticate header for 401 responses', async () => {
|
||||
mockRequest.headers = {};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
`Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include resource_metadata in WWW-Authenticate header when token verification fails', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer invalid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
`Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include resource_metadata in WWW-Authenticate header for insufficient scope errors', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: admin'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
`Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include resource_metadata when token is expired', async () => {
|
||||
const expiredAuthInfo: AuthInfo = {
|
||||
token: 'expired-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer expired-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
`Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include resource_metadata when scope check fails', async () => {
|
||||
const authInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read']
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(authInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ['read', 'write'],
|
||||
resourceMetadataUrl
|
||||
});
|
||||
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
`Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not affect server errors (no WWW-Authenticate header)', async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: 'Bearer valid-token'
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue'));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.set).not.toHaveBeenCalledWith('WWW-Authenticate', expect.anything());
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { authenticateClient, ClientAuthenticationMiddlewareOptions } from './clientAuth.js';
|
||||
import { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { OAuthClientInformationFull } from '../../../shared/auth.js';
|
||||
import express from 'express';
|
||||
import supertest from 'supertest';
|
||||
|
||||
describe('clientAuth middleware', () => {
|
||||
// Mock client store
|
||||
const mockClientStore: OAuthRegisteredClientsStore = {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
if (clientId === 'valid-client') {
|
||||
return {
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
} else if (clientId === 'expired-client') {
|
||||
// Client with no secret
|
||||
return {
|
||||
client_id: 'expired-client',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
} else if (clientId === 'client-with-expired-secret') {
|
||||
// Client with an expired secret
|
||||
return {
|
||||
client_id: 'client-with-expired-secret',
|
||||
client_secret: 'expired-secret',
|
||||
client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Setup Express app with middleware
|
||||
let app: express.Express;
|
||||
let options: ClientAuthenticationMiddlewareOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
options = {
|
||||
clientsStore: mockClientStore
|
||||
};
|
||||
|
||||
// Setup route with client auth
|
||||
app.post('/protected', authenticateClient(options), (req, res) => {
|
||||
res.status(200).json({ success: true, client: req.client });
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates valid client credentials', async () => {
|
||||
const response = await supertest(app).post('/protected').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.client.client_id).toBe('valid-client');
|
||||
});
|
||||
|
||||
it('rejects invalid client_id', async () => {
|
||||
const response = await supertest(app).post('/protected').send({
|
||||
client_id: 'non-existent-client',
|
||||
client_secret: 'some-secret'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_client');
|
||||
expect(response.body.error_description).toBe('Invalid client_id');
|
||||
});
|
||||
|
||||
it('rejects invalid client_secret', async () => {
|
||||
const response = await supertest(app).post('/protected').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'wrong-secret'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_client');
|
||||
expect(response.body.error_description).toBe('Invalid client_secret');
|
||||
});
|
||||
|
||||
it('rejects missing client_id', async () => {
|
||||
const response = await supertest(app).post('/protected').send({
|
||||
client_secret: 'valid-secret'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('allows missing client_secret if client has none', async () => {
|
||||
const response = await supertest(app).post('/protected').send({
|
||||
client_id: 'expired-client'
|
||||
});
|
||||
|
||||
// Since the client has no secret, this should pass without providing one
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects request when client secret has expired', async () => {
|
||||
const response = await supertest(app).post('/protected').send({
|
||||
client_id: 'client-with-expired-secret',
|
||||
client_secret: 'expired-secret'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_client');
|
||||
expect(response.body.error_description).toBe('Client secret has expired');
|
||||
});
|
||||
|
||||
it('handles malformed request body', async () => {
|
||||
const response = await supertest(app).post('/protected').send('not-json-format');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
// Testing request with extra fields to ensure they're ignored
|
||||
it('ignores extra fields in request', async () => {
|
||||
const response = await supertest(app).post('/protected').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
extra_field: 'should be ignored'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
import { Response } from 'express';
|
||||
import { ProxyOAuthServerProvider, ProxyOptions } from './proxyProvider.js';
|
||||
import { AuthInfo } from '../types.js';
|
||||
import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js';
|
||||
import { ServerError } from '../errors.js';
|
||||
import { InvalidTokenError } from '../errors.js';
|
||||
import { InsufficientScopeError } from '../errors.js';
|
||||
import { type Mock } from 'vitest';
|
||||
|
||||
describe('Proxy OAuth Server Provider', () => {
|
||||
// Mock client data
|
||||
const validClient: OAuthClientInformationFull = {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
|
||||
// Mock response object
|
||||
const mockResponse = {
|
||||
redirect: vi.fn()
|
||||
} as unknown as Response;
|
||||
|
||||
// Mock provider functions
|
||||
const mockVerifyToken = vi.fn();
|
||||
const mockGetClient = vi.fn();
|
||||
|
||||
// Base provider options
|
||||
const baseOptions: ProxyOptions = {
|
||||
endpoints: {
|
||||
authorizationUrl: 'https://auth.example.com/authorize',
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
revocationUrl: 'https://auth.example.com/revoke',
|
||||
registrationUrl: 'https://auth.example.com/register'
|
||||
},
|
||||
verifyAccessToken: mockVerifyToken,
|
||||
getClient: mockGetClient
|
||||
};
|
||||
|
||||
let provider: ProxyOAuthServerProvider;
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ProxyOAuthServerProvider(baseOptions);
|
||||
originalFetch = global.fetch;
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Setup mock implementations
|
||||
mockVerifyToken.mockImplementation(async (token: string) => {
|
||||
if (token === 'valid-token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
} as AuthInfo;
|
||||
}
|
||||
throw new InvalidTokenError('Invalid token');
|
||||
});
|
||||
|
||||
mockGetClient.mockImplementation(async (clientId: string) => {
|
||||
if (clientId === 'test-client') {
|
||||
return validClient;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
// Add helper function for failed responses
|
||||
const mockFailedResponse = () => {
|
||||
(global.fetch as Mock).mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('authorization', () => {
|
||||
it('redirects to authorization endpoint with correct parameters', async () => {
|
||||
await provider.authorize(
|
||||
validClient,
|
||||
{
|
||||
redirectUri: 'https://example.com/callback',
|
||||
codeChallenge: 'test-challenge',
|
||||
state: 'test-state',
|
||||
scopes: ['read', 'write'],
|
||||
resource: new URL('https://api.example.com/resource')
|
||||
},
|
||||
mockResponse
|
||||
);
|
||||
|
||||
const expectedUrl = new URL('https://auth.example.com/authorize');
|
||||
expectedUrl.searchParams.set('client_id', 'test-client');
|
||||
expectedUrl.searchParams.set('response_type', 'code');
|
||||
expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback');
|
||||
expectedUrl.searchParams.set('code_challenge', 'test-challenge');
|
||||
expectedUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
expectedUrl.searchParams.set('state', 'test-state');
|
||||
expectedUrl.searchParams.set('scope', 'read write');
|
||||
expectedUrl.searchParams.set('resource', 'https://api.example.com/resource');
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('token exchange', () => {
|
||||
const mockTokenResponse: OAuthTokens = {
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh-token'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(global.fetch as Mock).mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('exchanges authorization code for tokens', async () => {
|
||||
const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining('grant_type=authorization_code')
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('includes redirect_uri in token request when provided', async () => {
|
||||
const redirectUri = 'https://example.com/callback';
|
||||
const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier', redirectUri);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('includes resource parameter in authorization code exchange', async () => {
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
validClient,
|
||||
'test-code',
|
||||
'test-verifier',
|
||||
'https://example.com/callback',
|
||||
new URL('https://api.example.com/resource')
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('handles authorization code exchange without resource parameter', async () => {
|
||||
const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier');
|
||||
|
||||
const fetchCall = (global.fetch as Mock).mock.calls[0];
|
||||
const body = fetchCall[1].body as string;
|
||||
expect(body).not.toContain('resource=');
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('exchanges refresh token for new tokens', async () => {
|
||||
const tokens = await provider.exchangeRefreshToken(validClient, 'test-refresh-token', ['read', 'write']);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining('grant_type=refresh_token')
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('includes resource parameter in refresh token exchange', async () => {
|
||||
const tokens = await provider.exchangeRefreshToken(
|
||||
validClient,
|
||||
'test-refresh-token',
|
||||
['read', 'write'],
|
||||
new URL('https://api.example.com/resource')
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client registration', () => {
|
||||
it('registers new client', async () => {
|
||||
const newClient: OAuthClientInformationFull = {
|
||||
client_id: 'new-client',
|
||||
redirect_uris: ['https://new-client.com/callback']
|
||||
};
|
||||
|
||||
(global.fetch as Mock).mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(newClient)
|
||||
})
|
||||
);
|
||||
|
||||
const result = await provider.clientsStore.registerClient!(newClient);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/register',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newClient)
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(newClient);
|
||||
});
|
||||
|
||||
it('handles registration failure', async () => {
|
||||
mockFailedResponse();
|
||||
const newClient: OAuthClientInformationFull = {
|
||||
client_id: 'new-client',
|
||||
redirect_uris: ['https://new-client.com/callback']
|
||||
};
|
||||
|
||||
await expect(provider.clientsStore.registerClient!(newClient)).rejects.toThrow(ServerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token revocation', () => {
|
||||
it('revokes token', async () => {
|
||||
(global.fetch as Mock).mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true
|
||||
})
|
||||
);
|
||||
|
||||
await provider.revokeToken!(validClient, {
|
||||
token: 'token-to-revoke',
|
||||
token_type_hint: 'access_token'
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining('token=token-to-revoke')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles revocation failure', async () => {
|
||||
mockFailedResponse();
|
||||
await expect(
|
||||
provider.revokeToken!(validClient, {
|
||||
token: 'invalid-token'
|
||||
})
|
||||
).rejects.toThrow(ServerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token verification', () => {
|
||||
it('verifies valid token', async () => {
|
||||
const validAuthInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'test-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
mockVerifyToken.mockResolvedValue(validAuthInfo);
|
||||
|
||||
const authInfo = await provider.verifyAccessToken('valid-token');
|
||||
expect(authInfo).toEqual(validAuthInfo);
|
||||
expect(mockVerifyToken).toHaveBeenCalledWith('valid-token');
|
||||
});
|
||||
|
||||
it('passes through InvalidTokenError', async () => {
|
||||
const error = new InvalidTokenError('Token expired');
|
||||
mockVerifyToken.mockRejectedValue(error);
|
||||
|
||||
await expect(provider.verifyAccessToken('invalid-token')).rejects.toBe(error);
|
||||
expect(mockVerifyToken).toHaveBeenCalledWith('invalid-token');
|
||||
});
|
||||
|
||||
it('passes through InsufficientScopeError', async () => {
|
||||
const error = new InsufficientScopeError('Required scopes: read, write');
|
||||
mockVerifyToken.mockRejectedValue(error);
|
||||
|
||||
await expect(provider.verifyAccessToken('token-with-insufficient-scope')).rejects.toBe(error);
|
||||
expect(mockVerifyToken).toHaveBeenCalledWith('token-with-insufficient-scope');
|
||||
});
|
||||
|
||||
it('passes through unexpected errors', async () => {
|
||||
const error = new Error('Unexpected error');
|
||||
mockVerifyToken.mockRejectedValue(error);
|
||||
|
||||
await expect(provider.verifyAccessToken('valid-token')).rejects.toBe(error);
|
||||
expect(mockVerifyToken).toHaveBeenCalledWith('valid-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,463 +0,0 @@
|
|||
import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from './router.js';
|
||||
import { OAuthServerProvider, AuthorizationParams } from './provider.js';
|
||||
import { OAuthRegisteredClientsStore } from './clients.js';
|
||||
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js';
|
||||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { AuthInfo } from './types.js';
|
||||
import { InvalidTokenError } from './errors.js';
|
||||
|
||||
describe('MCP Auth Router', () => {
|
||||
// Setup mock provider with full capabilities
|
||||
const mockClientStore: OAuthRegisteredClientsStore = {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
if (clientId === 'valid-client') {
|
||||
return {
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
|
||||
return client;
|
||||
}
|
||||
};
|
||||
|
||||
const mockProvider: OAuthServerProvider = {
|
||||
clientsStore: mockClientStore,
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
const redirectUrl = new URL(params.redirectUri);
|
||||
redirectUrl.searchParams.set('code', 'mock_auth_code');
|
||||
if (params.state) {
|
||||
redirectUrl.searchParams.set('state', params.state);
|
||||
}
|
||||
res.redirect(302, redirectUrl.toString());
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(): Promise<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'new_mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
// Success - do nothing in mock
|
||||
}
|
||||
};
|
||||
|
||||
// Provider without registration and revocation
|
||||
const mockProviderMinimal: OAuthServerProvider = {
|
||||
clientsStore: {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
if (clientId === 'valid-client') {
|
||||
return {
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
const redirectUrl = new URL(params.redirectUri);
|
||||
redirectUrl.searchParams.set('code', 'mock_auth_code');
|
||||
if (params.state) {
|
||||
redirectUrl.searchParams.set('state', params.state);
|
||||
}
|
||||
res.redirect(302, redirectUrl.toString());
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(): Promise<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'new_mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
}
|
||||
};
|
||||
|
||||
describe('Router creation', () => {
|
||||
it('throws error for non-HTTPS issuer URL', () => {
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('http://auth.example.com')
|
||||
};
|
||||
|
||||
expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must be HTTPS');
|
||||
});
|
||||
|
||||
it('allows localhost HTTP for development', () => {
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('http://localhost:3000')
|
||||
};
|
||||
|
||||
expect(() => mcpAuthRouter(options)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws error for issuer URL with fragment', () => {
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('https://auth.example.com#fragment')
|
||||
};
|
||||
|
||||
expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a fragment');
|
||||
});
|
||||
|
||||
it('throws error for issuer URL with query string', () => {
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('https://auth.example.com?param=value')
|
||||
};
|
||||
|
||||
expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a query string');
|
||||
});
|
||||
|
||||
it('successfully creates router with valid options', () => {
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('https://auth.example.com')
|
||||
};
|
||||
|
||||
expect(() => mcpAuthRouter(options)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metadata endpoint', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup full-featured router
|
||||
app = express();
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('https://auth.example.com'),
|
||||
serviceDocumentationUrl: new URL('https://docs.example.com')
|
||||
};
|
||||
app.use(mcpAuthRouter(options));
|
||||
});
|
||||
|
||||
it('returns complete metadata for full-featured router', async () => {
|
||||
const response = await supertest(app).get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify essential fields
|
||||
expect(response.body.issuer).toBe('https://auth.example.com/');
|
||||
expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize');
|
||||
expect(response.body.token_endpoint).toBe('https://auth.example.com/token');
|
||||
expect(response.body.registration_endpoint).toBe('https://auth.example.com/register');
|
||||
expect(response.body.revocation_endpoint).toBe('https://auth.example.com/revoke');
|
||||
|
||||
// Verify supported features
|
||||
expect(response.body.response_types_supported).toEqual(['code']);
|
||||
expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']);
|
||||
expect(response.body.code_challenge_methods_supported).toEqual(['S256']);
|
||||
expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post', 'none']);
|
||||
expect(response.body.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_post']);
|
||||
|
||||
// Verify optional fields
|
||||
expect(response.body.service_documentation).toBe('https://docs.example.com/');
|
||||
});
|
||||
|
||||
it('returns minimal metadata for minimal router', async () => {
|
||||
// Setup minimal router
|
||||
const minimalApp = express();
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProviderMinimal,
|
||||
issuerUrl: new URL('https://auth.example.com')
|
||||
};
|
||||
minimalApp.use(mcpAuthRouter(options));
|
||||
|
||||
const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify essential endpoints
|
||||
expect(response.body.issuer).toBe('https://auth.example.com/');
|
||||
expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize');
|
||||
expect(response.body.token_endpoint).toBe('https://auth.example.com/token');
|
||||
|
||||
// Verify missing optional endpoints
|
||||
expect(response.body.registration_endpoint).toBeUndefined();
|
||||
expect(response.body.revocation_endpoint).toBeUndefined();
|
||||
expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined();
|
||||
expect(response.body.service_documentation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('provides protected resource metadata', async () => {
|
||||
// Setup router with draft protocol version
|
||||
const draftApp = express();
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('https://mcp.example.com'),
|
||||
scopesSupported: ['read', 'write'],
|
||||
resourceName: 'Test API'
|
||||
};
|
||||
draftApp.use(mcpAuthRouter(options));
|
||||
|
||||
const response = await supertest(draftApp).get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify protected resource metadata
|
||||
expect(response.body.resource).toBe('https://mcp.example.com/');
|
||||
expect(response.body.authorization_servers).toContain('https://mcp.example.com/');
|
||||
expect(response.body.scopes_supported).toEqual(['read', 'write']);
|
||||
expect(response.body.resource_name).toBe('Test API');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Endpoint routing', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup full-featured router
|
||||
app = express();
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('https://auth.example.com')
|
||||
};
|
||||
app.use(mcpAuthRouter(options));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('routes to authorization endpoint', async () => {
|
||||
const response = await supertest(app).get('/authorize').query({
|
||||
client_id: 'valid-client',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.header.location);
|
||||
expect(location.searchParams.has('code')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes to token endpoint', async () => {
|
||||
// Setup verifyChallenge mock for token handler
|
||||
vi.mock('pkce-challenge', () => ({
|
||||
verifyChallenge: vi.fn().mockResolvedValue(true)
|
||||
}));
|
||||
|
||||
const response = await supertest(app).post('/token').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
});
|
||||
|
||||
// The request will fail in testing due to mocking limitations,
|
||||
// but we can verify the route was matched
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('routes to registration endpoint', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/register')
|
||||
.send({
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
});
|
||||
|
||||
// The request will fail in testing due to mocking limitations,
|
||||
// but we can verify the route was matched
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('routes to revocation endpoint', async () => {
|
||||
const response = await supertest(app).post('/revoke').type('form').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
token: 'token_to_revoke'
|
||||
});
|
||||
|
||||
// The request will fail in testing due to mocking limitations,
|
||||
// but we can verify the route was matched
|
||||
expect(response.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('excludes endpoints for unsupported features', async () => {
|
||||
// Setup minimal router
|
||||
const minimalApp = express();
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProviderMinimal,
|
||||
issuerUrl: new URL('https://auth.example.com')
|
||||
};
|
||||
minimalApp.use(mcpAuthRouter(options));
|
||||
|
||||
// Registration should not be available
|
||||
const regResponse = await supertest(minimalApp)
|
||||
.post('/register')
|
||||
.send({
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
});
|
||||
expect(regResponse.status).toBe(404);
|
||||
|
||||
// Revocation should not be available
|
||||
const revokeResponse = await supertest(minimalApp).post('/revoke').send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
token: 'token_to_revoke'
|
||||
});
|
||||
expect(revokeResponse.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Auth Metadata Router', () => {
|
||||
const mockOAuthMetadata: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com/',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post']
|
||||
};
|
||||
|
||||
describe('Router creation', () => {
|
||||
it('successfully creates router with valid options', () => {
|
||||
const options: AuthMetadataOptions = {
|
||||
oauthMetadata: mockOAuthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com')
|
||||
};
|
||||
|
||||
expect(() => mcpAuthMetadataRouter(options)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metadata endpoints', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
const options: AuthMetadataOptions = {
|
||||
oauthMetadata: mockOAuthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com'),
|
||||
serviceDocumentationUrl: new URL('https://docs.example.com'),
|
||||
scopesSupported: ['read', 'write'],
|
||||
resourceName: 'Test API'
|
||||
};
|
||||
app.use(mcpAuthMetadataRouter(options));
|
||||
});
|
||||
|
||||
it('returns OAuth authorization server metadata', async () => {
|
||||
const response = await supertest(app).get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify metadata points to authorization server
|
||||
expect(response.body.issuer).toBe('https://auth.example.com/');
|
||||
expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize');
|
||||
expect(response.body.token_endpoint).toBe('https://auth.example.com/token');
|
||||
expect(response.body.response_types_supported).toEqual(['code']);
|
||||
expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']);
|
||||
expect(response.body.code_challenge_methods_supported).toEqual(['S256']);
|
||||
expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']);
|
||||
});
|
||||
|
||||
it('returns OAuth protected resource metadata', async () => {
|
||||
const response = await supertest(app).get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify protected resource metadata
|
||||
expect(response.body.resource).toBe('https://api.example.com/');
|
||||
expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']);
|
||||
expect(response.body.scopes_supported).toEqual(['read', 'write']);
|
||||
expect(response.body.resource_name).toBe('Test API');
|
||||
expect(response.body.resource_documentation).toBe('https://docs.example.com/');
|
||||
});
|
||||
|
||||
it('works with minimal configuration', async () => {
|
||||
const minimalApp = express();
|
||||
const options: AuthMetadataOptions = {
|
||||
oauthMetadata: mockOAuthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com')
|
||||
};
|
||||
minimalApp.use(mcpAuthMetadataRouter(options));
|
||||
|
||||
const authResponse = await supertest(minimalApp).get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(authResponse.status).toBe(200);
|
||||
expect(authResponse.body.issuer).toBe('https://auth.example.com/');
|
||||
expect(authResponse.body.service_documentation).toBeUndefined();
|
||||
expect(authResponse.body.scopes_supported).toBeUndefined();
|
||||
|
||||
const resourceResponse = await supertest(minimalApp).get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(resourceResponse.status).toBe(200);
|
||||
expect(resourceResponse.body.resource).toBe('https://api.example.com/');
|
||||
expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']);
|
||||
expect(resourceResponse.body.scopes_supported).toBeUndefined();
|
||||
expect(resourceResponse.body.resource_name).toBeUndefined();
|
||||
expect(resourceResponse.body.resource_documentation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { completable, getCompleter } from './completable.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
|
||||
|
||||
describe.each(zodTestMatrix)('completable with $zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
it('preserves types and values of underlying schema', () => {
|
||||
const baseSchema = z.string();
|
||||
const schema = completable(baseSchema, () => []);
|
||||
|
||||
expect(schema.parse('test')).toBe('test');
|
||||
expect(() => schema.parse(123)).toThrow();
|
||||
});
|
||||
|
||||
it('provides access to completion function', async () => {
|
||||
const completions = ['foo', 'bar', 'baz'];
|
||||
const schema = completable(z.string(), () => completions);
|
||||
|
||||
const completer = getCompleter(schema);
|
||||
expect(completer).toBeDefined();
|
||||
expect(await completer!('')).toEqual(completions);
|
||||
});
|
||||
|
||||
it('allows async completion functions', async () => {
|
||||
const completions = ['foo', 'bar', 'baz'];
|
||||
const schema = completable(z.string(), async () => completions);
|
||||
|
||||
const completer = getCompleter(schema);
|
||||
expect(completer).toBeDefined();
|
||||
expect(await completer!('')).toEqual(completions);
|
||||
});
|
||||
|
||||
it('passes current value to completion function', async () => {
|
||||
const schema = completable(z.string(), value => [value + '!']);
|
||||
|
||||
const completer = getCompleter(schema);
|
||||
expect(completer).toBeDefined();
|
||||
expect(await completer!('test')).toEqual(['test!']);
|
||||
});
|
||||
|
||||
it('works with number schemas', async () => {
|
||||
const schema = completable(z.number(), () => [1, 2, 3]);
|
||||
|
||||
expect(schema.parse(1)).toBe(1);
|
||||
const completer = getCompleter(schema);
|
||||
expect(completer).toBeDefined();
|
||||
expect(await completer!(0)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('preserves schema description', () => {
|
||||
const desc = 'test description';
|
||||
const schema = completable(z.string().describe(desc), () => []);
|
||||
|
||||
expect(schema.description).toBe(desc);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,988 +0,0 @@
|
|||
/**
|
||||
* Comprehensive elicitation flow tests with validator integration
|
||||
*
|
||||
* These tests verify the end-to-end elicitation flow from server requesting
|
||||
* input to client responding and validation of the response against schemas.
|
||||
*
|
||||
* Per the MCP spec, elicitation only supports object schemas, not primitives.
|
||||
*/
|
||||
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { ElicitRequestFormParams, ElicitRequestSchema } from '../types.js';
|
||||
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
|
||||
import { CfWorkerJsonSchemaValidator } from '../validation/cfworker-provider.js';
|
||||
import { Server } from './index.js';
|
||||
|
||||
const ajvProvider = new AjvJsonSchemaValidator();
|
||||
const cfWorkerProvider = new CfWorkerJsonSchemaValidator();
|
||||
|
||||
let server: Server;
|
||||
let client: Client;
|
||||
|
||||
describe('Elicitation Flow', () => {
|
||||
describe('with AJV validator', () => {
|
||||
beforeEach(async () => {
|
||||
server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: ajvProvider
|
||||
}
|
||||
);
|
||||
|
||||
client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
});
|
||||
|
||||
testElicitationFlow(ajvProvider, 'AJV');
|
||||
});
|
||||
|
||||
describe('with CfWorker validator', () => {
|
||||
beforeEach(async () => {
|
||||
server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: cfWorkerProvider
|
||||
}
|
||||
);
|
||||
|
||||
client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
});
|
||||
|
||||
testElicitationFlow(cfWorkerProvider, 'CfWorker');
|
||||
});
|
||||
});
|
||||
|
||||
function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWorkerProvider, validatorName: string) {
|
||||
test(`${validatorName}: should elicit simple object with string field`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with integer field`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with boolean field`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Do you agree?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agree: { type: 'boolean' }
|
||||
},
|
||||
required: ['agree']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit complex object with multiple fields`, async () => {
|
||||
const userData = {
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
age: 28,
|
||||
street: '123 Main St',
|
||||
city: 'San Francisco',
|
||||
zipCode: '94105',
|
||||
newsletter: true,
|
||||
notifications: false
|
||||
};
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
}));
|
||||
|
||||
const formRequestParams: ElicitRequestFormParams = {
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email' },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 },
|
||||
street: { type: 'string' },
|
||||
city: { type: 'string' },
|
||||
// @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' },
|
||||
newsletter: { type: 'boolean' },
|
||||
notifications: { type: 'boolean' }
|
||||
},
|
||||
required: ['name', 'email', 'age', 'street', 'city', 'zipCode']
|
||||
}
|
||||
};
|
||||
const result = await server.elicitInput(formRequestParams);
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid object (missing required field)`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
email: 'user@example.com'
|
||||
// Missing required 'name' field
|
||||
}
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' }
|
||||
},
|
||||
required: ['name', 'email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid field type`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
name: 'John Doe',
|
||||
age: 'thirty' // Wrong type - should be integer
|
||||
}
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'integer' }
|
||||
},
|
||||
required: ['name', 'age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid string (too short)`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: '' } // Too short
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid integer (out of range)`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 200 } // Too high
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid pattern`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { zipCode: 'ABC123' } // Doesn't match pattern
|
||||
}));
|
||||
|
||||
const formRequestParams: ElicitRequestFormParams = {
|
||||
mode: 'form',
|
||||
message: 'Enter a 5-digit zip code',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
// @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' }
|
||||
},
|
||||
required: ['zipCode']
|
||||
}
|
||||
};
|
||||
|
||||
await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow decline action without validation`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'decline'
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'decline'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow cancel action without validation`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'cancel'
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'cancel'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => {
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
requestCount++;
|
||||
if (request.params.message.includes('name')) {
|
||||
return { action: 'accept', content: { name: 'Alice' } };
|
||||
} else if (request.params.message.includes('age')) {
|
||||
return { action: 'accept', content: { age: 30 } };
|
||||
} else if (request.params.message.includes('city')) {
|
||||
return { action: 'accept', content: { city: 'New York' } };
|
||||
}
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
const nameResult = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', minLength: 1 } },
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
const ageResult = await server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { age: { type: 'integer', minimum: 0 } },
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
const cityResult = await server.elicitInput({
|
||||
message: 'What is your city?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string', minLength: 1 } },
|
||||
required: ['city']
|
||||
}
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(3);
|
||||
expect(nameResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'Alice' }
|
||||
});
|
||||
expect(ageResult).toEqual({ action: 'accept', content: { age: 30 } });
|
||||
expect(cityResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { city: 'New York' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields present`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields absent`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate email format`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should default missing fields from schema defaults`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'test-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {
|
||||
form: {
|
||||
applyDefaults: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const testSchemaProperties: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subscribe: { type: 'boolean', default: true },
|
||||
nickname: { type: 'string', default: 'Guest' },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150, default: 18 },
|
||||
color: { type: 'string', enum: ['red', 'green'], default: 'green' },
|
||||
untitledSingleSelectEnum: {
|
||||
type: 'string',
|
||||
title: 'Untitled Single Select Enum',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['red', 'green', 'blue'],
|
||||
default: 'green'
|
||||
},
|
||||
untitledMultipleSelectEnum: {
|
||||
type: 'array',
|
||||
title: 'Untitled Multiple Select Enum',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: { type: 'string', enum: ['red', 'green', 'blue'] },
|
||||
default: ['green', 'blue']
|
||||
},
|
||||
titledSingleSelectEnum: {
|
||||
type: 'string',
|
||||
title: 'Single Select Enum',
|
||||
description: 'Choose your favorite color',
|
||||
oneOf: [
|
||||
{ const: 'red', title: 'Red' },
|
||||
{ const: 'green', title: 'Green' },
|
||||
{ const: 'blue', title: 'Blue' }
|
||||
],
|
||||
default: 'green'
|
||||
},
|
||||
titledMultipleSelectEnum: {
|
||||
type: 'array',
|
||||
title: 'Multiple Select Enum',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: 'red', title: 'Red' },
|
||||
{ const: 'green', title: 'Green' },
|
||||
{ const: 'blue', title: 'Blue' }
|
||||
]
|
||||
},
|
||||
default: ['green', 'blue']
|
||||
},
|
||||
legacyTitledEnum: {
|
||||
type: 'string',
|
||||
title: 'Legacy Titled Enum',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['red', 'green', 'blue'],
|
||||
enumNames: ['Red', 'Green', 'Blue'],
|
||||
default: 'green'
|
||||
},
|
||||
optionalWithADefault: { type: 'string', default: 'default value' }
|
||||
},
|
||||
required: [
|
||||
'subscribe',
|
||||
'nickname',
|
||||
'age',
|
||||
'color',
|
||||
'titledSingleSelectEnum',
|
||||
'titledMultipleSelectEnum',
|
||||
'untitledSingleSelectEnum',
|
||||
'untitledMultipleSelectEnum'
|
||||
]
|
||||
};
|
||||
|
||||
// Client returns no values; SDK should apply defaults automatically (and validate)
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
expect(request.params.mode).toEqual('form');
|
||||
expect((request.params as ElicitRequestFormParams).requestedSchema).toEqual(testSchemaProperties);
|
||||
return {
|
||||
action: 'accept',
|
||||
content: {}
|
||||
};
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Provide your preferences',
|
||||
requestedSchema: testSchemaProperties
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
subscribe: true,
|
||||
nickname: 'Guest',
|
||||
age: 18,
|
||||
color: 'green',
|
||||
untitledSingleSelectEnum: 'green',
|
||||
untitledMultipleSelectEnum: ['green', 'blue'],
|
||||
titledSingleSelectEnum: 'green',
|
||||
titledMultipleSelectEnum: ['green', 'blue'],
|
||||
legacyTitledEnum: 'green',
|
||||
optionalWithADefault: 'default value'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid email format`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'not-an-email' }
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
// Enums - Valid - Single Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in single-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red'
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['Red', 'Green', 'Blue'],
|
||||
default: 'Green'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in single-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
oneOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in single-select titled legacy enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['#FF0000', '#00FF00', '#0000FF'],
|
||||
enumNames: ['Red', 'Green', 'Blue'],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enums - Valid - Multi Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in multi-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['Red', 'Blue']
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
colors: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['Red', 'Green', 'Blue']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['colors']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['Red', 'Blue']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in multi-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['#FF0000', '#0000FF']
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
colors: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['colors']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['#FF0000', '#0000FF']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enums - Invalid - Single Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in single-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Black' // Color not in enum list
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['Red', 'Green', 'Blue'],
|
||||
default: 'Green'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in single-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red' // Should be "#FF0000" (const not title)
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
oneOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in single-select titled legacy enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red' // Should be "#FF0000" (enum not enumNames)
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['#FF0000', '#00FF00', '#0000FF'],
|
||||
enumNames: ['Red', 'Green', 'Blue'],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
// Enums - Invalid - Multi Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in multi-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red' // Should be array, not string
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['Red', 'Green', 'Blue']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in multi-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['Red', 'Blue'] // Should be ["#FF0000", "#0000FF"] (const not title)
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
colors: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['colors']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,736 +0,0 @@
|
|||
import http from 'node:http';
|
||||
import { type Mocked } from 'vitest';
|
||||
|
||||
import { SSEServerTransport } from './sse.js';
|
||||
import { McpServer } from './mcp.js';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { CallToolResult, JSONRPCMessage } from '../types.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
|
||||
|
||||
const createMockResponse = () => {
|
||||
const res = {
|
||||
writeHead: vi.fn<http.ServerResponse['writeHead']>().mockReturnThis(),
|
||||
write: vi.fn<http.ServerResponse['write']>().mockReturnThis(),
|
||||
on: vi.fn<http.ServerResponse['on']>().mockReturnThis(),
|
||||
end: vi.fn<http.ServerResponse['end']>().mockReturnThis()
|
||||
};
|
||||
|
||||
return res as unknown as Mocked<http.ServerResponse>;
|
||||
};
|
||||
|
||||
const createMockRequest = ({ headers = {}, body }: { headers?: Record<string, string>; body?: string } = {}) => {
|
||||
const mockReq = {
|
||||
headers,
|
||||
body: body ? body : undefined,
|
||||
auth: {
|
||||
token: 'test-token'
|
||||
},
|
||||
on: vi.fn<http.IncomingMessage['on']>().mockImplementation((event, listener) => {
|
||||
const mockListener = listener as unknown as (...args: unknown[]) => void;
|
||||
if (event === 'data') {
|
||||
mockListener(Buffer.from(body || '') as unknown as Error);
|
||||
}
|
||||
if (event === 'error') {
|
||||
mockListener(new Error('test'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
mockListener();
|
||||
}
|
||||
if (event === 'close') {
|
||||
setTimeout(listener, 100);
|
||||
}
|
||||
return mockReq;
|
||||
}),
|
||||
listeners: vi.fn<http.IncomingMessage['listeners']>(),
|
||||
removeListener: vi.fn<http.IncomingMessage['removeListener']>()
|
||||
} as unknown as http.IncomingMessage;
|
||||
|
||||
return mockReq;
|
||||
};
|
||||
|
||||
async function readAllSSEEvents(response: Response): Promise<string[]> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No readable stream');
|
||||
|
||||
const events: string[] = [];
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
if (value) {
|
||||
events.push(decoder.decode(value));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to send JSON-RPC request
|
||||
*/
|
||||
async function sendSsePostRequest(
|
||||
baseUrl: URL,
|
||||
message: JSONRPCMessage | JSONRPCMessage[],
|
||||
sessionId?: string,
|
||||
extraHeaders?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
...extraHeaders
|
||||
};
|
||||
|
||||
if (sessionId) {
|
||||
baseUrl.searchParams.set('sessionId', sessionId);
|
||||
}
|
||||
|
||||
return fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
}
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
|
||||
/**
|
||||
* Helper to create and start test HTTP server with MCP setup
|
||||
*/
|
||||
async function createTestServerWithSse(args: { mockRes: http.ServerResponse }): Promise<{
|
||||
server: Server;
|
||||
transport: SSEServerTransport;
|
||||
mcpServer: McpServer;
|
||||
baseUrl: URL;
|
||||
sessionId: string;
|
||||
serverPort: number;
|
||||
}> {
|
||||
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
|
||||
|
||||
mcpServer.tool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{ name: z.string().describe('Name to greet') },
|
||||
async ({ name }): Promise<CallToolResult> => {
|
||||
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
|
||||
}
|
||||
);
|
||||
|
||||
const endpoint = '/messages';
|
||||
|
||||
const transport = new SSEServerTransport(endpoint, args.mockRes);
|
||||
const sessionId = transport.sessionId;
|
||||
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
if (!res.headersSent) res.writeHead(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = await new Promise<URL>(resolve => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
resolve(new URL(`http://127.0.0.1:${addr.port}`));
|
||||
});
|
||||
});
|
||||
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port };
|
||||
}
|
||||
|
||||
describe('SSEServerTransport', () => {
|
||||
async function initializeServer(baseUrl: URL): Promise<void> {
|
||||
const response = await sendSsePostRequest(baseUrl, {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
clientInfo: { name: 'test-client', version: '1.0' },
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {}
|
||||
},
|
||||
|
||||
id: 'init-1'
|
||||
} as JSONRPCMessage);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
|
||||
const text = await readAllSSEEvents(response);
|
||||
|
||||
expect(text).toHaveLength(1);
|
||||
expect(text[0]).toBe('Accepted');
|
||||
}
|
||||
|
||||
describe('start method', () => {
|
||||
it('should correctly append sessionId to a simple relative endpoint', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
const expectedSessionId = transport.sessionId;
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}\n\n`);
|
||||
});
|
||||
|
||||
it('should correctly append sessionId to an endpoint with existing query parameters', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages?foo=bar&baz=qux';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
const expectedSessionId = transport.sessionId;
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(
|
||||
`event: endpoint\ndata: /messages?foo=bar&baz=qux&sessionId=${expectedSessionId}\n\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly append sessionId to an endpoint with a hash fragment', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages#section1';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
const expectedSessionId = transport.sessionId;
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}#section1\n\n`);
|
||||
});
|
||||
|
||||
it('should correctly append sessionId to an endpoint with query parameters and a hash fragment', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages?key=value#section2';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
const expectedSessionId = transport.sessionId;
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(
|
||||
`event: endpoint\ndata: /messages?key=value&sessionId=${expectedSessionId}#section2\n\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly handle the root path endpoint "/"', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
const expectedSessionId = transport.sessionId;
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`);
|
||||
});
|
||||
|
||||
it('should correctly handle an empty string endpoint ""', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
const expectedSessionId = transport.sessionId;
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Tool With Request Info
|
||||
*/
|
||||
it('should pass request info to tool callback', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes });
|
||||
await initializeServer(baseUrl);
|
||||
|
||||
mcpServer.tool(
|
||||
'test-request-info',
|
||||
'A simple test tool with request info',
|
||||
{ name: z.string().describe('Name to greet') },
|
||||
async ({ name }, { requestInfo }): Promise<CallToolResult> => {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Hello, ${name}!` },
|
||||
{ type: 'text', text: `${JSON.stringify(requestInfo)}` }
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const toolCallMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'test-request-info',
|
||||
arguments: {
|
||||
name: 'Test User'
|
||||
}
|
||||
},
|
||||
id: 'call-1'
|
||||
};
|
||||
|
||||
const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
|
||||
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`);
|
||||
|
||||
const expectedMessage = {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello, Test User!'
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
headers: {
|
||||
host: `127.0.0.1:${serverPort}`,
|
||||
connection: 'keep-alive',
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream',
|
||||
'accept-language': '*',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'user-agent': 'node',
|
||||
'accept-encoding': 'gzip, deflate',
|
||||
'content-length': '124'
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
},
|
||||
jsonrpc: '2.0',
|
||||
id: 'call-1'
|
||||
};
|
||||
expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePostMessage method', () => {
|
||||
it('should return 500 if server has not started', async () => {
|
||||
const mockReq = createMockRequest();
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
|
||||
const error = 'SSE connection not established';
|
||||
await expect(transport.handlePostMessage(mockReq, mockRes)).rejects.toThrow(error);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.end).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should return 400 if content-type is not application/json', async () => {
|
||||
const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } });
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
|
||||
transport.onerror = vi.fn();
|
||||
const error = 'Unsupported content-type: text/plain';
|
||||
await expect(transport.handlePostMessage(mockReq, mockRes)).resolves.toBe(undefined);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error));
|
||||
expect(transport.onerror).toHaveBeenCalledWith(new Error(error));
|
||||
});
|
||||
|
||||
it('should return 400 if message has not a valid schema', async () => {
|
||||
const invalidMessage = JSON.stringify({
|
||||
// missing jsonrpc field
|
||||
method: 'call',
|
||||
params: [1, 2, 3],
|
||||
id: 1
|
||||
});
|
||||
const mockReq = createMockRequest({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: invalidMessage
|
||||
});
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
|
||||
transport.onmessage = vi.fn();
|
||||
await transport.handlePostMessage(mockReq, mockRes);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(400);
|
||||
expect(transport.onmessage).not.toHaveBeenCalled();
|
||||
expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`);
|
||||
});
|
||||
|
||||
it('should return 202 if message has a valid schema', async () => {
|
||||
const validMessage = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3
|
||||
},
|
||||
id: 1
|
||||
});
|
||||
const mockReq = createMockRequest({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: validMessage
|
||||
});
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
|
||||
transport.onmessage = vi.fn();
|
||||
await transport.handlePostMessage(mockReq, mockRes);
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockRes.end).toHaveBeenCalledWith('Accepted');
|
||||
expect(transport.onmessage).toHaveBeenCalledWith(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3
|
||||
},
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
authInfo: {
|
||||
token: 'test-token'
|
||||
},
|
||||
requestInfo: {
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close method', () => {
|
||||
it('should call onclose', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
transport.onclose = vi.fn();
|
||||
await transport.close();
|
||||
expect(transport.onclose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('send method', () => {
|
||||
it('should call onsend', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const endpoint = '/messages';
|
||||
const transport = new SSEServerTransport(endpoint, mockRes);
|
||||
await transport.start();
|
||||
expect(mockRes.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('event: endpoint'));
|
||||
expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS rebinding protection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Host header validation', () => {
|
||||
it('should accept requests with allowed host headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000', 'example.com'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should reject requests with disallowed host headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
host: 'evil.com',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
|
||||
});
|
||||
|
||||
it('should reject requests without host header when allowedHosts is configured', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Origin header validation', () => {
|
||||
it('should accept requests with allowed origin headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedOrigins: ['http://localhost:3000', 'https://example.com'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
origin: 'http://localhost:3000',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should accept requests without origin headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedOrigins: ['http://localhost:3000', 'https://example.com'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should reject requests with disallowed origin headers', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
origin: 'http://evil.com',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content-Type validation', () => {
|
||||
it('should accept requests with application/json content-type', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes);
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should accept requests with application/json with charset', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes);
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
|
||||
it('should reject requests with non-application/json content-type when protection is enabled', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes);
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'text/plain'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableDnsRebindingProtection option', () => {
|
||||
it('should skip all validations when enableDnsRebindingProtection is false', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: false
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
const mockReq = createMockRequest({
|
||||
headers: {
|
||||
host: 'evil.com',
|
||||
origin: 'http://evil.com',
|
||||
'content-type': 'text/plain'
|
||||
}
|
||||
});
|
||||
const mockHandleRes = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
// Should pass even with invalid headers because protection is disabled
|
||||
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
|
||||
// The error should be from content-type parsing, not DNS rebinding protection
|
||||
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined validations', () => {
|
||||
it('should validate both host and origin when both are configured', async () => {
|
||||
const mockRes = createMockResponse();
|
||||
const transport = new SSEServerTransport('/messages', mockRes, {
|
||||
allowedHosts: ['localhost:3000'],
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
// Valid host, invalid origin
|
||||
const mockReq1 = createMockRequest({
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://evil.com',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes1 = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
|
||||
|
||||
// Invalid host, valid origin
|
||||
const mockReq2 = createMockRequest({
|
||||
headers: {
|
||||
host: 'evil.com',
|
||||
origin: 'http://localhost:3000',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes2 = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
|
||||
|
||||
// Both valid
|
||||
const mockReq3 = createMockRequest({
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://localhost:3000',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
const mockHandleRes3 = createMockResponse();
|
||||
|
||||
await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' });
|
||||
|
||||
expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202);
|
||||
expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { Readable, Writable } from 'node:stream';
|
||||
import { ReadBuffer, serializeMessage } from '../shared/stdio.js';
|
||||
import { JSONRPCMessage } from '../types.js';
|
||||
import { StdioServerTransport } from './stdio.js';
|
||||
|
||||
let input: Readable;
|
||||
let outputBuffer: ReadBuffer;
|
||||
let output: Writable;
|
||||
|
||||
beforeEach(() => {
|
||||
input = new Readable({
|
||||
// We'll use input.push() instead.
|
||||
read: () => {}
|
||||
});
|
||||
|
||||
outputBuffer = new ReadBuffer();
|
||||
output = new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
outputBuffer.append(chunk);
|
||||
callback();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should start then close cleanly', async () => {
|
||||
const server = new StdioServerTransport(input, output);
|
||||
server.onerror = error => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
let didClose = false;
|
||||
server.onclose = () => {
|
||||
didClose = true;
|
||||
};
|
||||
|
||||
await server.start();
|
||||
expect(didClose).toBeFalsy();
|
||||
await server.close();
|
||||
expect(didClose).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should not read until started', async () => {
|
||||
const server = new StdioServerTransport(input, output);
|
||||
server.onerror = error => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
let didRead = false;
|
||||
const readMessage = new Promise(resolve => {
|
||||
server.onmessage = message => {
|
||||
didRead = true;
|
||||
resolve(message);
|
||||
};
|
||||
});
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'ping'
|
||||
};
|
||||
input.push(serializeMessage(message));
|
||||
|
||||
expect(didRead).toBeFalsy();
|
||||
await server.start();
|
||||
expect(await readMessage).toEqual(message);
|
||||
});
|
||||
|
||||
test('should read multiple messages', async () => {
|
||||
const server = new StdioServerTransport(input, output);
|
||||
server.onerror = error => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const messages: JSONRPCMessage[] = [
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'ping'
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized'
|
||||
}
|
||||
];
|
||||
|
||||
const readMessages: JSONRPCMessage[] = [];
|
||||
const finished = new Promise<void>(resolve => {
|
||||
server.onmessage = message => {
|
||||
readMessages.push(message);
|
||||
if (JSON.stringify(message) === JSON.stringify(messages[1])) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
input.push(serializeMessage(messages[0]));
|
||||
input.push(serializeMessage(messages[1]));
|
||||
|
||||
await server.start();
|
||||
await finished;
|
||||
expect(readMessages).toEqual(messages);
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,228 +0,0 @@
|
|||
import { Server } from './index.js';
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import { McpServer, ResourceTemplate } from './mcp.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
|
||||
describe('Title field backwards compatibility', () => {
|
||||
it('should work with tools that have title', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register tool with title
|
||||
server.registerTool(
|
||||
'test-tool',
|
||||
{
|
||||
title: 'Test Tool Display Name',
|
||||
description: 'A test tool',
|
||||
inputSchema: {
|
||||
value: z.string()
|
||||
}
|
||||
},
|
||||
async () => ({ content: [{ type: 'text', text: 'result' }] })
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(1);
|
||||
expect(tools.tools[0].name).toBe('test-tool');
|
||||
expect(tools.tools[0].title).toBe('Test Tool Display Name');
|
||||
expect(tools.tools[0].description).toBe('A test tool');
|
||||
});
|
||||
|
||||
it('should work with tools without title', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register tool without title
|
||||
server.tool('test-tool', 'A test tool', { value: z.string() }, async () => ({ content: [{ type: 'text', text: 'result' }] }));
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(1);
|
||||
expect(tools.tools[0].name).toBe('test-tool');
|
||||
expect(tools.tools[0].title).toBeUndefined();
|
||||
expect(tools.tools[0].description).toBe('A test tool');
|
||||
});
|
||||
|
||||
it('should work with prompts that have title using update', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register prompt with title by updating after creation
|
||||
const prompt = server.prompt('test-prompt', 'A test prompt', async () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'test' } }]
|
||||
}));
|
||||
prompt.update({ title: 'Test Prompt Display Name' });
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const prompts = await client.listPrompts();
|
||||
expect(prompts.prompts).toHaveLength(1);
|
||||
expect(prompts.prompts[0].name).toBe('test-prompt');
|
||||
expect(prompts.prompts[0].title).toBe('Test Prompt Display Name');
|
||||
expect(prompts.prompts[0].description).toBe('A test prompt');
|
||||
});
|
||||
|
||||
it('should work with prompts using registerPrompt', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register prompt with title using registerPrompt
|
||||
server.registerPrompt(
|
||||
'test-prompt',
|
||||
{
|
||||
title: 'Test Prompt Display Name',
|
||||
description: 'A test prompt',
|
||||
argsSchema: { input: z.string() }
|
||||
},
|
||||
async ({ input }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: { type: 'text', text: `test: ${input}` }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const prompts = await client.listPrompts();
|
||||
expect(prompts.prompts).toHaveLength(1);
|
||||
expect(prompts.prompts[0].name).toBe('test-prompt');
|
||||
expect(prompts.prompts[0].title).toBe('Test Prompt Display Name');
|
||||
expect(prompts.prompts[0].description).toBe('A test prompt');
|
||||
expect(prompts.prompts[0].arguments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should work with resources using registerResource', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register resource with title using registerResource
|
||||
server.registerResource(
|
||||
'test-resource',
|
||||
'https://example.com/test',
|
||||
{
|
||||
title: 'Test Resource Display Name',
|
||||
description: 'A test resource',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'https://example.com/test',
|
||||
text: 'test content'
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const resources = await client.listResources();
|
||||
expect(resources.resources).toHaveLength(1);
|
||||
expect(resources.resources[0].name).toBe('test-resource');
|
||||
expect(resources.resources[0].title).toBe('Test Resource Display Name');
|
||||
expect(resources.resources[0].description).toBe('A test resource');
|
||||
expect(resources.resources[0].mimeType).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('should work with dynamic resources using registerResource', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register dynamic resource with title using registerResource
|
||||
server.registerResource(
|
||||
'user-profile',
|
||||
new ResourceTemplate('users://{userId}/profile', { list: undefined }),
|
||||
{
|
||||
title: 'User Profile',
|
||||
description: 'User profile information'
|
||||
},
|
||||
async (uri, { userId }, _extra) => ({
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: `Profile data for user ${userId}`
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const resourceTemplates = await client.listResourceTemplates();
|
||||
expect(resourceTemplates.resourceTemplates).toHaveLength(1);
|
||||
expect(resourceTemplates.resourceTemplates[0].name).toBe('user-profile');
|
||||
expect(resourceTemplates.resourceTemplates[0].title).toBe('User Profile');
|
||||
expect(resourceTemplates.resourceTemplates[0].description).toBe('User profile information');
|
||||
expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe('users://{userId}/profile');
|
||||
|
||||
// Test reading the resource
|
||||
const readResult = await client.readResource({ uri: 'users://123/profile' });
|
||||
expect(readResult.contents).toHaveLength(1);
|
||||
expect(readResult.contents).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
text: expect.stringContaining('Profile data for user 123'),
|
||||
uri: 'users://123/profile'
|
||||
}
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should support serverInfo with title', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0',
|
||||
title: 'Test Server Display Name'
|
||||
},
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const serverInfo = client.getServerVersion();
|
||||
expect(serverInfo?.name).toBe('test-server');
|
||||
expect(serverInfo?.version).toBe('1.0.0');
|
||||
expect(serverInfo?.title).toBe('Test Server Display Name');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue