Relocate tests under /test (#1220)
This commit is contained in:
parent
7655eeb51c
commit
5a9de1c638
46 changed files with 630 additions and 524 deletions
|
|
@ -1,373 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { auth } from './auth.js';
|
||||
import {
|
||||
ClientCredentialsProvider,
|
||||
PrivateKeyJwtProvider,
|
||||
StaticPrivateKeyJwtProvider,
|
||||
createPrivateKeyJwtAuth
|
||||
} from './auth-extensions.js';
|
||||
import type { FetchLike } from '../shared/transport.js';
|
||||
|
||||
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
|
||||
const AUTH_SERVER_URL = 'https://auth.example.com';
|
||||
|
||||
function createMockFetch(onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise<void>): FetchLike {
|
||||
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = input instanceof URL ? input : new URL(input);
|
||||
|
||||
// Protected resource metadata discovery
|
||||
if (url.origin === RESOURCE_SERVER_URL.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resource: RESOURCE_SERVER_URL,
|
||||
authorization_servers: [AUTH_SERVER_URL]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Authorization server metadata discovery
|
||||
if (url.origin === AUTH_SERVER_URL && url.pathname === '/.well-known/oauth-authorization-server') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: AUTH_SERVER_URL,
|
||||
authorization_endpoint: `${AUTH_SERVER_URL}/authorize`,
|
||||
token_endpoint: `${AUTH_SERVER_URL}/token`,
|
||||
response_types_supported: ['code'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt']
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Token endpoint
|
||||
if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') {
|
||||
if (onTokenRequest) {
|
||||
await onTokenRequest(url, init);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer'
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL in mock fetch: ${url.toString()}`);
|
||||
};
|
||||
}
|
||||
|
||||
describe('auth-extensions providers (end-to-end with auth())', () => {
|
||||
it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => {
|
||||
const provider = new ClientCredentialsProvider({
|
||||
clientId: 'my-client',
|
||||
clientSecret: 'my-secret',
|
||||
clientName: 'test-client'
|
||||
});
|
||||
|
||||
const fetchMock = createMockFetch(async (_url, init) => {
|
||||
const params = init?.body as URLSearchParams;
|
||||
expect(params).toBeInstanceOf(URLSearchParams);
|
||||
expect(params.get('grant_type')).toBe('client_credentials');
|
||||
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
|
||||
expect(params.get('client_assertion')).toBeNull();
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
const authHeader = headers.get('Authorization');
|
||||
expect(authHeader).toBeTruthy();
|
||||
|
||||
const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64');
|
||||
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
|
||||
});
|
||||
|
||||
const result = await auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
});
|
||||
|
||||
expect(result).toBe('AUTHORIZED');
|
||||
const tokens = provider.tokens();
|
||||
expect(tokens).toBeTruthy();
|
||||
expect(tokens?.access_token).toBe('test-access-token');
|
||||
});
|
||||
|
||||
it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => {
|
||||
const provider = new PrivateKeyJwtProvider({
|
||||
clientId: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
algorithm: 'HS256',
|
||||
clientName: 'private-key-jwt-client'
|
||||
});
|
||||
|
||||
let assertionFromRequest: string | null = null;
|
||||
|
||||
const fetchMock = createMockFetch(async (_url, init) => {
|
||||
const params = init?.body as URLSearchParams;
|
||||
expect(params).toBeInstanceOf(URLSearchParams);
|
||||
expect(params.get('grant_type')).toBe('client_credentials');
|
||||
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
|
||||
|
||||
assertionFromRequest = params.get('client_assertion');
|
||||
expect(assertionFromRequest).toBeTruthy();
|
||||
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
|
||||
const parts = assertionFromRequest!.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
});
|
||||
|
||||
const result = await auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
});
|
||||
|
||||
expect(result).toBe('AUTHORIZED');
|
||||
const tokens = provider.tokens();
|
||||
expect(tokens).toBeTruthy();
|
||||
expect(tokens?.access_token).toBe('test-access-token');
|
||||
expect(assertionFromRequest).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => {
|
||||
const provider = new PrivateKeyJwtProvider({
|
||||
clientId: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
algorithm: 'none',
|
||||
clientName: 'private-key-jwt-client'
|
||||
});
|
||||
|
||||
const fetchMock = createMockFetch();
|
||||
|
||||
await expect(
|
||||
auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
})
|
||||
).rejects.toThrow('Unsupported algorithm none');
|
||||
});
|
||||
|
||||
it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => {
|
||||
const staticAssertion = 'header.payload.signature';
|
||||
|
||||
const provider = new StaticPrivateKeyJwtProvider({
|
||||
clientId: 'static-client',
|
||||
jwtBearerAssertion: staticAssertion,
|
||||
clientName: 'static-private-key-jwt-client'
|
||||
});
|
||||
|
||||
const fetchMock = createMockFetch(async (_url, init) => {
|
||||
const params = init?.body as URLSearchParams;
|
||||
expect(params).toBeInstanceOf(URLSearchParams);
|
||||
expect(params.get('grant_type')).toBe('client_credentials');
|
||||
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
|
||||
|
||||
expect(params.get('client_assertion')).toBe(staticAssertion);
|
||||
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
});
|
||||
|
||||
const result = await auth(provider, {
|
||||
serverUrl: RESOURCE_SERVER_URL,
|
||||
fetchFn: fetchMock
|
||||
});
|
||||
|
||||
expect(result).toBe('AUTHORIZED');
|
||||
const tokens = provider.tokens();
|
||||
expect(tokens).toBeTruthy();
|
||||
expect(tokens?.access_token).toBe('test-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPrivateKeyJwtAuth', () => {
|
||||
const baseOptions = {
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
alg: 'HS256'
|
||||
};
|
||||
|
||||
it('creates an addClientAuthentication function that sets JWT assertion params', async () => {
|
||||
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
|
||||
|
||||
const headers = new Headers();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
await addClientAuth(headers, params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
expect(params.get('client_assertion')).toBeTruthy();
|
||||
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||||
|
||||
// Verify JWT structure (three dot-separated segments)
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('throws when globalThis.crypto is not available', async () => {
|
||||
// Temporarily remove globalThis.crypto to simulate older Node.js runtimes
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globalAny = globalThis as any;
|
||||
const originalCrypto = globalAny.crypto;
|
||||
// Use delete so that typeof globalThis.crypto === 'undefined'
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete globalAny.crypto;
|
||||
|
||||
try {
|
||||
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions'
|
||||
);
|
||||
} finally {
|
||||
// Restore original crypto to avoid affecting other tests
|
||||
globalAny.crypto = originalCrypto;
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a signed JWT when using a Uint8Array HMAC key', async () => {
|
||||
const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long');
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: secret,
|
||||
alg: 'HS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('creates a signed JWT when using a symmetric JWK key', async () => {
|
||||
const jwk: Record<string, unknown> = {
|
||||
kty: 'oct',
|
||||
// "a-string-secret-at-least-256-bits-long" base64url-encoded
|
||||
k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc',
|
||||
alg: 'HS256'
|
||||
};
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: jwk,
|
||||
alg: 'HS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('creates a signed JWT when using an RSA PEM private key', async () => {
|
||||
// Generate an RSA key pair on the fly
|
||||
const jose = await import('jose');
|
||||
const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true });
|
||||
const pem = await jose.exportPKCS8(privateKey);
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: pem,
|
||||
alg: 'RS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
const parts = assertion.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('uses metadata.issuer as audience when available', async () => {
|
||||
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', {
|
||||
issuer: 'https://issuer.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
});
|
||||
|
||||
const assertion = params.get('client_assertion')!;
|
||||
// Decode the payload to verify audience
|
||||
const [, payloadB64] = assertion.split('.');
|
||||
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
||||
expect(payload.aud).toBe('https://issuer.example.com');
|
||||
});
|
||||
|
||||
it('throws when using an unsupported algorithm', async () => {
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
alg: 'none'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
'Unsupported algorithm none'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when jose cannot import an invalid RSA PEM key', async () => {
|
||||
const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----';
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: badPem,
|
||||
alg: 'RS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
/Invalid character/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when jose cannot import a mismatched JWK key', async () => {
|
||||
const jwk: Record<string, unknown> = {
|
||||
kty: 'oct',
|
||||
k: 'c2VjcmV0LWtleQ', // "secret-key" base64url
|
||||
alg: 'HS256'
|
||||
};
|
||||
|
||||
const addClientAuth = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-id',
|
||||
subject: 'client-id',
|
||||
privateKey: jwk,
|
||||
// Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail
|
||||
alg: 'RS256'
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
|
||||
/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,153 +0,0 @@
|
|||
import { StdioClientTransport, getDefaultEnvironment } from './stdio.js';
|
||||
import spawn from 'cross-spawn';
|
||||
import { JSONRPCMessage } from '../types.js';
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
import { Mock, MockedFunction } from 'vitest';
|
||||
|
||||
// mock cross-spawn
|
||||
vi.mock('cross-spawn');
|
||||
const mockSpawn = spawn as unknown as MockedFunction<typeof spawn>;
|
||||
|
||||
describe('StdioClientTransport using cross-spawn', () => {
|
||||
beforeEach(() => {
|
||||
// mock cross-spawn's return value
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const mockProcess: {
|
||||
on: Mock;
|
||||
stdin?: { on: Mock; write: Mock };
|
||||
stdout?: { on: Mock };
|
||||
stderr?: null;
|
||||
} = {
|
||||
on: vi.fn((event: string, callback: () => void) => {
|
||||
if (event === 'spawn') {
|
||||
callback();
|
||||
}
|
||||
return mockProcess;
|
||||
}),
|
||||
stdin: {
|
||||
on: vi.fn(),
|
||||
write: vi.fn().mockReturnValue(true)
|
||||
},
|
||||
stdout: {
|
||||
on: vi.fn()
|
||||
},
|
||||
stderr: null
|
||||
};
|
||||
return mockProcess as unknown as ChildProcess;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should call cross-spawn correctly', async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'test-command',
|
||||
args: ['arg1', 'arg2']
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
|
||||
// verify spawn is called correctly
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'test-command',
|
||||
['arg1', 'arg2'],
|
||||
expect.objectContaining({
|
||||
shell: false
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass environment variables correctly', async () => {
|
||||
const customEnv = { TEST_VAR: 'test-value' };
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'test-command',
|
||||
env: customEnv
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
|
||||
// verify environment variables are merged correctly
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'test-command',
|
||||
[],
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
...customEnv
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should use default environment when env is undefined', async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'test-command',
|
||||
env: undefined
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
|
||||
// verify default environment is used
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'test-command',
|
||||
[],
|
||||
expect.objectContaining({
|
||||
env: getDefaultEnvironment()
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should send messages correctly', async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'test-command'
|
||||
});
|
||||
|
||||
// get the mock process object
|
||||
const mockProcess: {
|
||||
on: Mock;
|
||||
stdin: {
|
||||
on: Mock;
|
||||
write: Mock;
|
||||
once: Mock;
|
||||
};
|
||||
stdout: {
|
||||
on: Mock;
|
||||
};
|
||||
stderr: null;
|
||||
} = {
|
||||
on: vi.fn((event: string, callback: () => void) => {
|
||||
if (event === 'spawn') {
|
||||
callback();
|
||||
}
|
||||
return mockProcess;
|
||||
}),
|
||||
stdin: {
|
||||
on: vi.fn(),
|
||||
write: vi.fn().mockReturnValue(true),
|
||||
once: vi.fn()
|
||||
},
|
||||
stdout: {
|
||||
on: vi.fn()
|
||||
},
|
||||
stderr: null
|
||||
};
|
||||
|
||||
mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess);
|
||||
|
||||
await transport.start();
|
||||
|
||||
// 关键修复:确保 jsonrpc 是字面量 "2.0"
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 'test-id',
|
||||
method: 'test-method'
|
||||
};
|
||||
|
||||
await transport.send(message);
|
||||
|
||||
// verify message is sent correctly
|
||||
expect(mockProcess.stdin.write).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,77 +0,0 @@
|
|||
import { JSONRPCMessage } from '../types.js';
|
||||
import { StdioClientTransport, StdioServerParameters } from './stdio.js';
|
||||
|
||||
// Configure default server parameters based on OS
|
||||
// Uses 'more' command for Windows and 'tee' command for Unix/Linux
|
||||
const getDefaultServerParameters = (): StdioServerParameters => {
|
||||
if (process.platform === 'win32') {
|
||||
return { command: 'more' };
|
||||
}
|
||||
return { command: '/usr/bin/tee' };
|
||||
};
|
||||
|
||||
const serverParameters = getDefaultServerParameters();
|
||||
|
||||
test('should start then close cleanly', async () => {
|
||||
const client = new StdioClientTransport(serverParameters);
|
||||
client.onerror = error => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
let didClose = false;
|
||||
client.onclose = () => {
|
||||
didClose = true;
|
||||
};
|
||||
|
||||
await client.start();
|
||||
expect(didClose).toBeFalsy();
|
||||
await client.close();
|
||||
expect(didClose).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should read messages', async () => {
|
||||
const client = new StdioClientTransport(serverParameters);
|
||||
client.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 => {
|
||||
client.onmessage = message => {
|
||||
readMessages.push(message);
|
||||
|
||||
if (JSON.stringify(message) === JSON.stringify(messages[1])) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await client.start();
|
||||
await client.send(messages[0]);
|
||||
await client.send(messages[1]);
|
||||
await finished;
|
||||
expect(readMessages).toEqual(messages);
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test('should return child process pid', async () => {
|
||||
const client = new StdioClientTransport(serverParameters);
|
||||
|
||||
await client.start();
|
||||
expect(client.pid).not.toBeNull();
|
||||
await client.close();
|
||||
expect(client.pid).toBeNull();
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,285 +0,0 @@
|
|||
import { Response } from 'express';
|
||||
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js';
|
||||
import { AuthorizationParams } from '../../server/auth/provider.js';
|
||||
import { OAuthClientInformationFull } from '../../shared/auth.js';
|
||||
import { InvalidRequestError } from '../../server/auth/errors.js';
|
||||
|
||||
describe('DemoInMemoryAuthProvider', () => {
|
||||
let provider: DemoInMemoryAuthProvider;
|
||||
let mockResponse: Response & { getRedirectUrl: () => string };
|
||||
|
||||
const createMockResponse = (): Response & { getRedirectUrl: () => string } => {
|
||||
let capturedRedirectUrl: string | undefined;
|
||||
|
||||
const mockRedirect = vi.fn().mockImplementation((url: string | number, status?: number) => {
|
||||
if (typeof url === 'string') {
|
||||
capturedRedirectUrl = url;
|
||||
} else if (typeof status === 'string') {
|
||||
capturedRedirectUrl = status;
|
||||
}
|
||||
return mockResponse;
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
redirect: mockRedirect,
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
getRedirectUrl: () => {
|
||||
if (capturedRedirectUrl === undefined) {
|
||||
throw new Error('No redirect URL was captured. Ensure redirect() was called first.');
|
||||
}
|
||||
return capturedRedirectUrl;
|
||||
}
|
||||
} as unknown as Response & { getRedirectUrl: () => string };
|
||||
|
||||
return mockResponse;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new DemoInMemoryAuthProvider();
|
||||
mockResponse = createMockResponse();
|
||||
});
|
||||
|
||||
describe('authorize', () => {
|
||||
const validClient: OAuthClientInformationFull = {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
redirect_uris: ['https://example.com/callback', 'https://example.com/callback2'],
|
||||
scope: 'test-scope'
|
||||
};
|
||||
|
||||
it('should redirect to the requested redirect_uri when valid', async () => {
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
await provider.authorize(validClient, params, mockResponse);
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalled();
|
||||
expect(mockResponse.getRedirectUrl()).toBeDefined();
|
||||
|
||||
const url = new URL(mockResponse.getRedirectUrl());
|
||||
expect(url.origin + url.pathname).toBe('https://example.com/callback');
|
||||
expect(url.searchParams.get('state')).toBe('test-state');
|
||||
expect(url.searchParams.has('code')).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw InvalidRequestError for unregistered redirect_uri', async () => {
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://evil.com/callback',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow(InvalidRequestError);
|
||||
|
||||
await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow('Unregistered redirect_uri');
|
||||
|
||||
expect(mockResponse.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate unique authorization codes for multiple requests', async () => {
|
||||
const params1: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'state-1',
|
||||
codeChallenge: 'challenge-1',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
const params2: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'state-2',
|
||||
codeChallenge: 'challenge-2',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
await provider.authorize(validClient, params1, mockResponse);
|
||||
const firstRedirectUrl = mockResponse.getRedirectUrl();
|
||||
const firstCode = new URL(firstRedirectUrl).searchParams.get('code');
|
||||
|
||||
// Reset the mock for the second call
|
||||
mockResponse = createMockResponse();
|
||||
await provider.authorize(validClient, params2, mockResponse);
|
||||
const secondRedirectUrl = mockResponse.getRedirectUrl();
|
||||
const secondCode = new URL(secondRedirectUrl).searchParams.get('code');
|
||||
|
||||
expect(firstCode).toBeDefined();
|
||||
expect(secondCode).toBeDefined();
|
||||
expect(firstCode).not.toBe(secondCode);
|
||||
});
|
||||
|
||||
it('should handle params without state', async () => {
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
codeChallenge: 'test-challenge',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
await provider.authorize(validClient, params, mockResponse);
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalled();
|
||||
expect(mockResponse.getRedirectUrl()).toBeDefined();
|
||||
|
||||
const url = new URL(mockResponse.getRedirectUrl());
|
||||
expect(url.searchParams.has('state')).toBe(false);
|
||||
expect(url.searchParams.has('code')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('challengeForAuthorizationCode', () => {
|
||||
const validClient: OAuthClientInformationFull = {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
scope: 'test-scope'
|
||||
};
|
||||
|
||||
it('should return the code challenge for a valid authorization code', async () => {
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge-value',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
await provider.authorize(validClient, params, mockResponse);
|
||||
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
|
||||
|
||||
const challenge = await provider.challengeForAuthorizationCode(validClient, code);
|
||||
expect(challenge).toBe('test-challenge-value');
|
||||
});
|
||||
|
||||
it('should throw error for invalid authorization code', async () => {
|
||||
await expect(provider.challengeForAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeAuthorizationCode', () => {
|
||||
const validClient: OAuthClientInformationFull = {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
scope: 'test-scope'
|
||||
};
|
||||
|
||||
it('should exchange valid authorization code for tokens', async () => {
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
scopes: ['test-scope', 'other-scope']
|
||||
};
|
||||
|
||||
await provider.authorize(validClient, params, mockResponse);
|
||||
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
|
||||
|
||||
const tokens = await provider.exchangeAuthorizationCode(validClient, code);
|
||||
|
||||
expect(tokens).toEqual({
|
||||
access_token: expect.any(String),
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
scope: 'test-scope other-scope'
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid authorization code', async () => {
|
||||
await expect(provider.exchangeAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code');
|
||||
});
|
||||
|
||||
it('should throw error when client_id does not match', async () => {
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
await provider.authorize(validClient, params, mockResponse);
|
||||
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
|
||||
|
||||
const differentClient: OAuthClientInformationFull = {
|
||||
client_id: 'different-client',
|
||||
client_secret: 'different-secret',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
scope: 'test-scope'
|
||||
};
|
||||
|
||||
await expect(provider.exchangeAuthorizationCode(differentClient, code)).rejects.toThrow(
|
||||
'Authorization code was not issued to this client'
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete authorization code after successful exchange', async () => {
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
scopes: ['test-scope']
|
||||
};
|
||||
|
||||
await provider.authorize(validClient, params, mockResponse);
|
||||
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
|
||||
|
||||
// First exchange should succeed
|
||||
await provider.exchangeAuthorizationCode(validClient, code);
|
||||
|
||||
// Second exchange should fail
|
||||
await expect(provider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow('Invalid authorization code');
|
||||
});
|
||||
|
||||
it('should validate resource when validateResource is provided', async () => {
|
||||
const validateResource = vi.fn().mockReturnValue(false);
|
||||
const strictProvider = new DemoInMemoryAuthProvider(validateResource);
|
||||
|
||||
const params: AuthorizationParams = {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
scopes: ['test-scope'],
|
||||
resource: new URL('https://invalid-resource.com')
|
||||
};
|
||||
|
||||
await strictProvider.authorize(validClient, params, mockResponse);
|
||||
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
|
||||
|
||||
await expect(strictProvider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow(
|
||||
'Invalid resource: https://invalid-resource.com/'
|
||||
);
|
||||
|
||||
expect(validateResource).toHaveBeenCalledWith(params.resource);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DemoInMemoryClientsStore', () => {
|
||||
let store: DemoInMemoryClientsStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new DemoInMemoryClientsStore();
|
||||
});
|
||||
|
||||
it('should register and retrieve client', async () => {
|
||||
const client: OAuthClientInformationFull = {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
scope: 'test-scope'
|
||||
};
|
||||
|
||||
await store.registerClient(client);
|
||||
const retrieved = await store.getClient('test-client');
|
||||
|
||||
expect(retrieved).toEqual(client);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent client', async () => {
|
||||
const retrieved = await store.getClient('non-existent');
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,936 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from './in-memory.js';
|
||||
import { TaskCreationParams, Request } from '../../../types.js';
|
||||
import { QueuedMessage } from '../interfaces.js';
|
||||
|
||||
describe('InMemoryTaskStore', () => {
|
||||
let store: InMemoryTaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new InMemoryTaskStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.cleanup();
|
||||
});
|
||||
|
||||
describe('createTask', () => {
|
||||
it('should create a new task with working status', async () => {
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: 60000
|
||||
};
|
||||
const request: Request = {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
};
|
||||
|
||||
const task = await store.createTask(taskParams, 123, request);
|
||||
|
||||
expect(task).toBeDefined();
|
||||
expect(task.taskId).toBeDefined();
|
||||
expect(typeof task.taskId).toBe('string');
|
||||
expect(task.taskId.length).toBeGreaterThan(0);
|
||||
expect(task.status).toBe('working');
|
||||
expect(task.ttl).toBe(60000);
|
||||
expect(task.pollInterval).toBeDefined();
|
||||
expect(task.createdAt).toBeDefined();
|
||||
expect(new Date(task.createdAt).getTime()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create task without ttl', async () => {
|
||||
const taskParams: TaskCreationParams = {};
|
||||
const request: Request = {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const task = await store.createTask(taskParams, 456, request);
|
||||
|
||||
expect(task).toBeDefined();
|
||||
expect(task.ttl).toBeNull();
|
||||
});
|
||||
|
||||
it('should generate unique taskIds', async () => {
|
||||
const taskParams: TaskCreationParams = {};
|
||||
const request: Request = {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const task1 = await store.createTask(taskParams, 789, request);
|
||||
const task2 = await store.createTask(taskParams, 790, request);
|
||||
|
||||
expect(task1.taskId).not.toBe(task2.taskId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTask', () => {
|
||||
it('should return null for non-existent task', async () => {
|
||||
const task = await store.getTask('non-existent');
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
|
||||
it('should return task state', async () => {
|
||||
const taskParams: TaskCreationParams = {};
|
||||
const request: Request = {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const createdTask = await store.createTask(taskParams, 111, request);
|
||||
await store.updateTaskStatus(createdTask.taskId, 'working');
|
||||
|
||||
const task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeDefined();
|
||||
expect(task?.status).toBe('working');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskStatus', () => {
|
||||
let taskId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const taskParams: TaskCreationParams = {};
|
||||
const createdTask = await store.createTask(taskParams, 222, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
taskId = createdTask.taskId;
|
||||
});
|
||||
|
||||
it('should keep task status as working', async () => {
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('working');
|
||||
});
|
||||
|
||||
it('should update task status to input_required', async () => {
|
||||
await store.updateTaskStatus(taskId, 'input_required');
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('input_required');
|
||||
});
|
||||
|
||||
it('should update task status to completed', async () => {
|
||||
await store.updateTaskStatus(taskId, 'completed');
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should update task status to failed with error', async () => {
|
||||
await store.updateTaskStatus(taskId, 'failed', 'Something went wrong');
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('failed');
|
||||
expect(task?.statusMessage).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should update task status to cancelled', async () => {
|
||||
await store.updateTaskStatus(taskId, 'cancelled');
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('should throw if task not found', async () => {
|
||||
await expect(store.updateTaskStatus('non-existent', 'working')).rejects.toThrow('Task with ID non-existent not found');
|
||||
});
|
||||
|
||||
describe('status lifecycle validation', () => {
|
||||
it('should allow transition from working to input_required', async () => {
|
||||
await store.updateTaskStatus(taskId, 'input_required');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('input_required');
|
||||
});
|
||||
|
||||
it('should allow transition from working to completed', async () => {
|
||||
await store.updateTaskStatus(taskId, 'completed');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should allow transition from working to failed', async () => {
|
||||
await store.updateTaskStatus(taskId, 'failed');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should allow transition from working to cancelled', async () => {
|
||||
await store.updateTaskStatus(taskId, 'cancelled');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('should allow transition from input_required to working', async () => {
|
||||
await store.updateTaskStatus(taskId, 'input_required');
|
||||
await store.updateTaskStatus(taskId, 'working');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('working');
|
||||
});
|
||||
|
||||
it('should allow transition from input_required to completed', async () => {
|
||||
await store.updateTaskStatus(taskId, 'input_required');
|
||||
await store.updateTaskStatus(taskId, 'completed');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should allow transition from input_required to failed', async () => {
|
||||
await store.updateTaskStatus(taskId, 'input_required');
|
||||
await store.updateTaskStatus(taskId, 'failed');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should allow transition from input_required to cancelled', async () => {
|
||||
await store.updateTaskStatus(taskId, 'input_required');
|
||||
await store.updateTaskStatus(taskId, 'cancelled');
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('should reject transition from completed to any other status', async () => {
|
||||
await store.updateTaskStatus(taskId, 'completed');
|
||||
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
|
||||
});
|
||||
|
||||
it('should reject transition from failed to any other status', async () => {
|
||||
await store.updateTaskStatus(taskId, 'failed');
|
||||
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
|
||||
});
|
||||
|
||||
it('should reject transition from cancelled to any other status', async () => {
|
||||
await store.updateTaskStatus(taskId, 'cancelled');
|
||||
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
|
||||
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeTaskResult', () => {
|
||||
let taskId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: 60000
|
||||
};
|
||||
const createdTask = await store.createTask(taskParams, 333, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
taskId = createdTask.taskId;
|
||||
});
|
||||
|
||||
it('should store task result and set status to completed', async () => {
|
||||
const result = {
|
||||
content: [{ type: 'text' as const, text: 'Success!' }]
|
||||
};
|
||||
|
||||
await store.storeTaskResult(taskId, 'completed', result);
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('completed');
|
||||
|
||||
const storedResult = await store.getTaskResult(taskId);
|
||||
expect(storedResult).toEqual(result);
|
||||
});
|
||||
|
||||
it('should throw if task not found', async () => {
|
||||
await expect(store.storeTaskResult('non-existent', 'completed', {})).rejects.toThrow('Task with ID non-existent not found');
|
||||
});
|
||||
|
||||
it('should reject storing result for task already in completed status', async () => {
|
||||
// First complete the task
|
||||
const firstResult = {
|
||||
content: [{ type: 'text' as const, text: 'First result' }]
|
||||
};
|
||||
await store.storeTaskResult(taskId, 'completed', firstResult);
|
||||
|
||||
// Try to store result again (should fail)
|
||||
const secondResult = {
|
||||
content: [{ type: 'text' as const, text: 'Second result' }]
|
||||
};
|
||||
|
||||
await expect(store.storeTaskResult(taskId, 'completed', secondResult)).rejects.toThrow('Cannot store result for task');
|
||||
});
|
||||
|
||||
it('should store result with failed status', async () => {
|
||||
const result = {
|
||||
content: [{ type: 'text' as const, text: 'Error details' }],
|
||||
isError: true
|
||||
};
|
||||
|
||||
await store.storeTaskResult(taskId, 'failed', result);
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('failed');
|
||||
|
||||
const storedResult = await store.getTaskResult(taskId);
|
||||
expect(storedResult).toEqual(result);
|
||||
});
|
||||
|
||||
it('should reject storing result for task already in failed status', async () => {
|
||||
// First fail the task
|
||||
const firstResult = {
|
||||
content: [{ type: 'text' as const, text: 'First error' }],
|
||||
isError: true
|
||||
};
|
||||
await store.storeTaskResult(taskId, 'failed', firstResult);
|
||||
|
||||
// Try to store result again (should fail)
|
||||
const secondResult = {
|
||||
content: [{ type: 'text' as const, text: 'Second error' }],
|
||||
isError: true
|
||||
};
|
||||
|
||||
await expect(store.storeTaskResult(taskId, 'failed', secondResult)).rejects.toThrow('Cannot store result for task');
|
||||
});
|
||||
|
||||
it('should reject storing result for cancelled task', async () => {
|
||||
// Mark task as cancelled
|
||||
await store.updateTaskStatus(taskId, 'cancelled');
|
||||
|
||||
// Try to store result (should fail)
|
||||
const result = {
|
||||
content: [{ type: 'text' as const, text: 'Cancellation result' }]
|
||||
};
|
||||
|
||||
await expect(store.storeTaskResult(taskId, 'completed', result)).rejects.toThrow('Cannot store result for task');
|
||||
});
|
||||
|
||||
it('should allow storing result from input_required status', async () => {
|
||||
await store.updateTaskStatus(taskId, 'input_required');
|
||||
|
||||
const result = {
|
||||
content: [{ type: 'text' as const, text: 'Success!' }]
|
||||
};
|
||||
|
||||
await store.storeTaskResult(taskId, 'completed', result);
|
||||
|
||||
const task = await store.getTask(taskId);
|
||||
expect(task?.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskResult', () => {
|
||||
it('should throw if task not found', async () => {
|
||||
await expect(store.getTaskResult('non-existent')).rejects.toThrow('Task with ID non-existent not found');
|
||||
});
|
||||
|
||||
it('should throw if task has no result stored', async () => {
|
||||
const taskParams: TaskCreationParams = {};
|
||||
const createdTask = await store.createTask(taskParams, 444, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
await expect(store.getTaskResult(createdTask.taskId)).rejects.toThrow(`Task ${createdTask.taskId} has no result stored`);
|
||||
});
|
||||
|
||||
it('should return stored result', async () => {
|
||||
const taskParams: TaskCreationParams = {};
|
||||
const createdTask = await store.createTask(taskParams, 555, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const result = {
|
||||
content: [{ type: 'text' as const, text: 'Result data' }]
|
||||
};
|
||||
await store.storeTaskResult(createdTask.taskId, 'completed', result);
|
||||
|
||||
const retrieved = await store.getTaskResult(createdTask.taskId);
|
||||
expect(retrieved).toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ttl cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should cleanup task after ttl duration', async () => {
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: 1000
|
||||
};
|
||||
const createdTask = await store.createTask(taskParams, 666, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
// Task should exist initially
|
||||
let task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeDefined();
|
||||
|
||||
// Fast-forward past ttl
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
// Task should be cleaned up
|
||||
task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
|
||||
it('should reset cleanup timer when result is stored', async () => {
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: 1000
|
||||
};
|
||||
const createdTask = await store.createTask(taskParams, 777, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
// Fast-forward 500ms
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// Store result (should reset timer)
|
||||
await store.storeTaskResult(createdTask.taskId, 'completed', {
|
||||
content: [{ type: 'text' as const, text: 'Done' }]
|
||||
});
|
||||
|
||||
// Fast-forward another 500ms (total 1000ms since creation, but timer was reset)
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// Task should still exist
|
||||
const task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeDefined();
|
||||
|
||||
// Fast-forward remaining time
|
||||
vi.advanceTimersByTime(501);
|
||||
|
||||
// Now task should be cleaned up
|
||||
const cleanedTask = await store.getTask(createdTask.taskId);
|
||||
expect(cleanedTask).toBeNull();
|
||||
});
|
||||
|
||||
it('should not cleanup tasks without ttl', async () => {
|
||||
const taskParams: TaskCreationParams = {};
|
||||
const createdTask = await store.createTask(taskParams, 888, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
// Fast-forward a long time
|
||||
vi.advanceTimersByTime(100000);
|
||||
|
||||
// Task should still exist
|
||||
const task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeDefined();
|
||||
});
|
||||
|
||||
it('should start cleanup timer when task reaches terminal state', async () => {
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: 1000
|
||||
};
|
||||
const createdTask = await store.createTask(taskParams, 999, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
// Task in non-terminal state, fast-forward
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
// Task should be cleaned up
|
||||
let task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeNull();
|
||||
|
||||
// Create another task
|
||||
const taskParams2: TaskCreationParams = {
|
||||
ttl: 2000
|
||||
};
|
||||
const createdTask2 = await store.createTask(taskParams2, 1000, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
// Update to terminal state
|
||||
await store.updateTaskStatus(createdTask2.taskId, 'completed');
|
||||
|
||||
// Fast-forward past original ttl
|
||||
vi.advanceTimersByTime(2001);
|
||||
|
||||
// Task should be cleaned up
|
||||
task = await store.getTask(createdTask2.taskId);
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
|
||||
it('should return actual TTL in task response', async () => {
|
||||
// Test that the TaskStore returns the actual TTL it will use
|
||||
// This implementation uses the requested TTL as-is, but implementations
|
||||
// MAY override it (e.g., enforce maximum TTL limits)
|
||||
const requestedTtl = 5000;
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: requestedTtl
|
||||
};
|
||||
const createdTask = await store.createTask(taskParams, 1111, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
// The returned task should include the actual TTL that will be used
|
||||
expect(createdTask.ttl).toBe(requestedTtl);
|
||||
|
||||
// Verify the task is cleaned up after the actual TTL
|
||||
vi.advanceTimersByTime(requestedTtl + 1);
|
||||
const task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
|
||||
it('should support null TTL for unlimited lifetime', async () => {
|
||||
// Test that null TTL means unlimited lifetime
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: null
|
||||
};
|
||||
const createdTask = await store.createTask(taskParams, 2222, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
// The returned task should have null TTL
|
||||
expect(createdTask.ttl).toBeNull();
|
||||
|
||||
// Task should not be cleaned up even after a long time
|
||||
vi.advanceTimersByTime(100000);
|
||||
const task = await store.getTask(createdTask.taskId);
|
||||
expect(task).toBeDefined();
|
||||
expect(task?.taskId).toBe(createdTask.taskId);
|
||||
});
|
||||
|
||||
it('should cleanup tasks regardless of status', async () => {
|
||||
// Test that TTL cleanup happens regardless of task status
|
||||
const taskParams: TaskCreationParams = {
|
||||
ttl: 1000
|
||||
};
|
||||
|
||||
// Create tasks in different statuses
|
||||
const workingTask = await store.createTask(taskParams, 3333, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const completedTask = await store.createTask(taskParams, 4444, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
await store.storeTaskResult(completedTask.taskId, 'completed', {
|
||||
content: [{ type: 'text' as const, text: 'Done' }]
|
||||
});
|
||||
|
||||
const failedTask = await store.createTask(taskParams, 5555, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
await store.storeTaskResult(failedTask.taskId, 'failed', {
|
||||
content: [{ type: 'text' as const, text: 'Error' }]
|
||||
});
|
||||
|
||||
// Fast-forward past TTL
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
// All tasks should be cleaned up regardless of status
|
||||
expect(await store.getTask(workingTask.taskId)).toBeNull();
|
||||
expect(await store.getTask(completedTask.taskId)).toBeNull();
|
||||
expect(await store.getTask(failedTask.taskId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllTasks', () => {
|
||||
it('should return all tasks', async () => {
|
||||
await store.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
await store.createTask({}, 2, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
await store.createTask({}, 3, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const tasks = store.getAllTasks();
|
||||
expect(tasks).toHaveLength(3);
|
||||
// Verify all tasks have unique IDs
|
||||
const taskIds = tasks.map(t => t.taskId);
|
||||
expect(new Set(taskIds).size).toBe(3);
|
||||
});
|
||||
|
||||
it('should return empty array when no tasks', () => {
|
||||
const tasks = store.getAllTasks();
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTasks', () => {
|
||||
it('should return empty list when no tasks', async () => {
|
||||
const result = await store.listTasks();
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return all tasks when less than page size', async () => {
|
||||
await store.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
await store.createTask({}, 2, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
await store.createTask({}, 3, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const result = await store.listTasks();
|
||||
expect(result.tasks).toHaveLength(3);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should paginate when more than page size', async () => {
|
||||
// Create 15 tasks (page size is 10)
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await store.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Get first page
|
||||
const page1 = await store.listTasks();
|
||||
expect(page1.tasks).toHaveLength(10);
|
||||
expect(page1.nextCursor).toBeDefined();
|
||||
|
||||
// Get second page using cursor
|
||||
const page2 = await store.listTasks(page1.nextCursor);
|
||||
expect(page2.tasks).toHaveLength(5);
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error for invalid cursor', async () => {
|
||||
await store.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
await expect(store.listTasks('non-existent-cursor')).rejects.toThrow('Invalid cursor: non-existent-cursor');
|
||||
});
|
||||
|
||||
it('should continue from cursor correctly', async () => {
|
||||
// Create 5 tasks
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await store.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Get first 3 tasks
|
||||
const allTaskIds = Array.from(store.getAllTasks().map(t => t.taskId));
|
||||
const result = await store.listTasks(allTaskIds[2]);
|
||||
|
||||
// Should get tasks after the third task
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clear all timers and tasks', async () => {
|
||||
await store.createTask({ ttl: 1000 }, 1, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
await store.createTask({ ttl: 2000 }, 2, {
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(store.getAllTasks()).toHaveLength(2);
|
||||
|
||||
store.cleanup();
|
||||
|
||||
expect(store.getAllTasks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('InMemoryTaskMessageQueue', () => {
|
||||
let queue: InMemoryTaskMessageQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new InMemoryTaskMessageQueue();
|
||||
});
|
||||
|
||||
describe('enqueue and dequeue', () => {
|
||||
it('should enqueue and dequeue request messages', async () => {
|
||||
const requestMessage: QueuedMessage = {
|
||||
type: 'request',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool', arguments: {} }
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await queue.enqueue('task-1', requestMessage);
|
||||
const dequeued = await queue.dequeue('task-1');
|
||||
|
||||
expect(dequeued).toEqual(requestMessage);
|
||||
});
|
||||
|
||||
it('should enqueue and dequeue notification messages', async () => {
|
||||
const notificationMessage: QueuedMessage = {
|
||||
type: 'notification',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/progress',
|
||||
params: { progress: 50, total: 100 }
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await queue.enqueue('task-2', notificationMessage);
|
||||
const dequeued = await queue.dequeue('task-2');
|
||||
|
||||
expect(dequeued).toEqual(notificationMessage);
|
||||
});
|
||||
|
||||
it('should enqueue and dequeue response messages', async () => {
|
||||
const responseMessage: QueuedMessage = {
|
||||
type: 'response',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 42,
|
||||
result: { content: [{ type: 'text', text: 'Success' }] }
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await queue.enqueue('task-3', responseMessage);
|
||||
const dequeued = await queue.dequeue('task-3');
|
||||
|
||||
expect(dequeued).toEqual(responseMessage);
|
||||
});
|
||||
|
||||
it('should return undefined when dequeuing from empty queue', async () => {
|
||||
const dequeued = await queue.dequeue('task-empty');
|
||||
expect(dequeued).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should maintain FIFO order for mixed message types', async () => {
|
||||
const request: QueuedMessage = {
|
||||
type: 'request',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
},
|
||||
timestamp: 1000
|
||||
};
|
||||
|
||||
const notification: QueuedMessage = {
|
||||
type: 'notification',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/progress',
|
||||
params: {}
|
||||
},
|
||||
timestamp: 2000
|
||||
};
|
||||
|
||||
const response: QueuedMessage = {
|
||||
type: 'response',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {}
|
||||
},
|
||||
timestamp: 3000
|
||||
};
|
||||
|
||||
await queue.enqueue('task-fifo', request);
|
||||
await queue.enqueue('task-fifo', notification);
|
||||
await queue.enqueue('task-fifo', response);
|
||||
|
||||
expect(await queue.dequeue('task-fifo')).toEqual(request);
|
||||
expect(await queue.dequeue('task-fifo')).toEqual(notification);
|
||||
expect(await queue.dequeue('task-fifo')).toEqual(response);
|
||||
expect(await queue.dequeue('task-fifo')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dequeueAll', () => {
|
||||
it('should dequeue all messages including responses', async () => {
|
||||
const request: QueuedMessage = {
|
||||
type: 'request',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {}
|
||||
},
|
||||
timestamp: 1000
|
||||
};
|
||||
|
||||
const response: QueuedMessage = {
|
||||
type: 'response',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {}
|
||||
},
|
||||
timestamp: 2000
|
||||
};
|
||||
|
||||
const notification: QueuedMessage = {
|
||||
type: 'notification',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/progress',
|
||||
params: {}
|
||||
},
|
||||
timestamp: 3000
|
||||
};
|
||||
|
||||
await queue.enqueue('task-all', request);
|
||||
await queue.enqueue('task-all', response);
|
||||
await queue.enqueue('task-all', notification);
|
||||
|
||||
const all = await queue.dequeueAll('task-all');
|
||||
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all[0]).toEqual(request);
|
||||
expect(all[1]).toEqual(response);
|
||||
expect(all[2]).toEqual(notification);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent task', async () => {
|
||||
const all = await queue.dequeueAll('non-existent');
|
||||
expect(all).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear the queue after dequeueAll', async () => {
|
||||
const message: QueuedMessage = {
|
||||
type: 'request',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'test',
|
||||
params: {}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await queue.enqueue('task-clear', message);
|
||||
await queue.dequeueAll('task-clear');
|
||||
|
||||
const dequeued = await queue.dequeue('task-clear');
|
||||
expect(dequeued).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue size limits', () => {
|
||||
it('should throw when maxSize is exceeded', async () => {
|
||||
const message: QueuedMessage = {
|
||||
type: 'request',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'test',
|
||||
params: {}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await queue.enqueue('task-limit', message, undefined, 2);
|
||||
await queue.enqueue('task-limit', message, undefined, 2);
|
||||
|
||||
await expect(queue.enqueue('task-limit', message, undefined, 2)).rejects.toThrow('Task message queue overflow');
|
||||
});
|
||||
|
||||
it('should allow enqueue when under maxSize', async () => {
|
||||
const message: QueuedMessage = {
|
||||
type: 'response',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await expect(queue.enqueue('task-ok', message, undefined, 5)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('task isolation', () => {
|
||||
it('should isolate messages between different tasks', async () => {
|
||||
const message1: QueuedMessage = {
|
||||
type: 'request',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'test1',
|
||||
params: {}
|
||||
},
|
||||
timestamp: 1000
|
||||
};
|
||||
|
||||
const message2: QueuedMessage = {
|
||||
type: 'response',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
result: {}
|
||||
},
|
||||
timestamp: 2000
|
||||
};
|
||||
|
||||
await queue.enqueue('task-a', message1);
|
||||
await queue.enqueue('task-b', message2);
|
||||
|
||||
expect(await queue.dequeue('task-a')).toEqual(message1);
|
||||
expect(await queue.dequeue('task-b')).toEqual(message2);
|
||||
expect(await queue.dequeue('task-a')).toBeUndefined();
|
||||
expect(await queue.dequeue('task-b')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('response message error handling', () => {
|
||||
it('should handle response messages with errors', async () => {
|
||||
const errorResponse: QueuedMessage = {
|
||||
type: 'error',
|
||||
message: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: {
|
||||
code: -32600,
|
||||
message: 'Invalid Request'
|
||||
}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await queue.enqueue('task-error', errorResponse);
|
||||
const dequeued = await queue.dequeue('task-error');
|
||||
|
||||
expect(dequeued).toEqual(errorResponse);
|
||||
expect(dequeued?.type).toBe('error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { InMemoryTransport } from '../../inMemory.js';
|
||||
import { Client } from '../../client/index.js';
|
||||
import { Server } from '../../server/index.js';
|
||||
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from './stores/in-memory.js';
|
||||
import { ErrorCode, McpError } from '../../types.js';
|
||||
|
||||
describe('Task Listing with Pagination', () => {
|
||||
let client: Client;
|
||||
let server: Server;
|
||||
let taskStore: InMemoryTaskStore;
|
||||
let clientTransport: InMemoryTransport;
|
||||
let serverTransport: InMemoryTransport;
|
||||
|
||||
beforeEach(async () => {
|
||||
taskStore = new InMemoryTaskStore();
|
||||
|
||||
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
taskStore.cleanup();
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('should return empty list when no tasks exist', async () => {
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return all tasks when less than page size', async () => {
|
||||
// Create 3 tasks
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(3);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should paginate when more than page size exists', async () => {
|
||||
// Create 15 tasks (page size is 10 in InMemoryTaskStore)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get first page
|
||||
const page1 = await client.experimental.tasks.listTasks();
|
||||
expect(page1.tasks).toHaveLength(10);
|
||||
expect(page1.nextCursor).toBeDefined();
|
||||
|
||||
// Get second page using cursor
|
||||
const page2 = await client.experimental.tasks.listTasks(page1.nextCursor);
|
||||
expect(page2.tasks).toHaveLength(5);
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat cursor as opaque token', async () => {
|
||||
// Create 5 tasks
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get all tasks to get a valid cursor
|
||||
const allTasks = taskStore.getAllTasks();
|
||||
const validCursor = allTasks[2].taskId;
|
||||
|
||||
// Use the cursor - should work even though we don't know its internal structure
|
||||
const result = await client.experimental.tasks.listTasks(validCursor);
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return error code -32602 for invalid cursor', async () => {
|
||||
await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
// Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec
|
||||
await expect(client.experimental.tasks.listTasks('invalid-cursor')).rejects.toSatisfy((error: McpError) => {
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.code).toBe(ErrorCode.InvalidParams);
|
||||
expect(error.message).toContain('Invalid cursor');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => {
|
||||
// Create a task
|
||||
const task = await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
// Verify it's accessible via tasks/get
|
||||
const getResult = await client.experimental.tasks.getTask(task.taskId);
|
||||
expect(getResult.taskId).toBe(task.taskId);
|
||||
|
||||
// Verify it's also accessible via tasks/list
|
||||
const listResult = await client.experimental.tasks.listTasks();
|
||||
expect(listResult.tasks).toHaveLength(1);
|
||||
expect(listResult.tasks[0].taskId).toBe(task.taskId);
|
||||
});
|
||||
|
||||
it('should not include related-task metadata in list response', async () => {
|
||||
// Create a task
|
||||
await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
// The response should have _meta but not include related-task metadata
|
||||
expect(result._meta).toBeDefined();
|
||||
expect(result._meta?.['io.modelcontextprotocol/related-task']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { isTerminal } from './interfaces.js';
|
||||
import type { Task } from '../../types.js';
|
||||
|
||||
describe('Task utility functions', () => {
|
||||
describe('isTerminal', () => {
|
||||
it('should return true for completed status', () => {
|
||||
expect(isTerminal('completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for failed status', () => {
|
||||
expect(isTerminal('failed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for cancelled status', () => {
|
||||
expect(isTerminal('cancelled')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for working status', () => {
|
||||
expect(isTerminal('working')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for input_required status', () => {
|
||||
expect(isTerminal('input_required')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Schema Validation', () => {
|
||||
it('should validate task with ttl field', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-123',
|
||||
status: 'working',
|
||||
ttl: 60000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
pollInterval: 1000
|
||||
};
|
||||
|
||||
expect(task.ttl).toBe(60000);
|
||||
expect(task.createdAt).toBeDefined();
|
||||
expect(typeof task.createdAt).toBe('string');
|
||||
});
|
||||
|
||||
it('should validate task with null ttl', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-456',
|
||||
status: 'completed',
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.ttl).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate task with statusMessage field', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-789',
|
||||
status: 'failed',
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
statusMessage: 'Operation failed due to timeout'
|
||||
};
|
||||
|
||||
expect(task.statusMessage).toBe('Operation failed due to timeout');
|
||||
});
|
||||
|
||||
it('should validate task with createdAt in ISO 8601 format', () => {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-iso',
|
||||
status: 'working',
|
||||
ttl: 30000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(new Date(task.createdAt).getTime()).toBe(now.getTime());
|
||||
});
|
||||
|
||||
it('should validate task with lastUpdatedAt in ISO 8601 format', () => {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-iso',
|
||||
status: 'working',
|
||||
ttl: 30000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.lastUpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('should validate all task statuses', () => {
|
||||
const statuses: Task['status'][] = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
statuses.forEach(status => {
|
||||
const task: Task = {
|
||||
taskId: `test-${status}`,
|
||||
status,
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
expect(task.status).toBe(status);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { InMemoryTransport } from './inMemory.js';
|
||||
import { JSONRPCMessage } from './types.js';
|
||||
import { AuthInfo } from './server/auth/types.js';
|
||||
|
||||
describe('InMemoryTransport', () => {
|
||||
let clientTransport: InMemoryTransport;
|
||||
let serverTransport: InMemoryTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
});
|
||||
|
||||
test('should create linked pair', () => {
|
||||
expect(clientTransport).toBeDefined();
|
||||
expect(serverTransport).toBeDefined();
|
||||
});
|
||||
|
||||
test('should start without error', async () => {
|
||||
await expect(clientTransport.start()).resolves.not.toThrow();
|
||||
await expect(serverTransport.start()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('should send message from client to server', async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'test',
|
||||
id: 1
|
||||
};
|
||||
|
||||
let receivedMessage: JSONRPCMessage | undefined;
|
||||
serverTransport.onmessage = msg => {
|
||||
receivedMessage = msg;
|
||||
};
|
||||
|
||||
await clientTransport.send(message);
|
||||
expect(receivedMessage).toEqual(message);
|
||||
});
|
||||
|
||||
test('should send message with auth info from client to server', async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'test',
|
||||
id: 1
|
||||
};
|
||||
|
||||
const authInfo: AuthInfo = {
|
||||
token: 'test-token',
|
||||
clientId: 'test-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
|
||||
let receivedMessage: JSONRPCMessage | undefined;
|
||||
let receivedAuthInfo: AuthInfo | undefined;
|
||||
serverTransport.onmessage = (msg, extra) => {
|
||||
receivedMessage = msg;
|
||||
receivedAuthInfo = extra?.authInfo;
|
||||
};
|
||||
|
||||
await clientTransport.send(message, { authInfo });
|
||||
expect(receivedMessage).toEqual(message);
|
||||
expect(receivedAuthInfo).toEqual(authInfo);
|
||||
});
|
||||
|
||||
test('should send message from server to client', async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'test',
|
||||
id: 1
|
||||
};
|
||||
|
||||
let receivedMessage: JSONRPCMessage | undefined;
|
||||
clientTransport.onmessage = msg => {
|
||||
receivedMessage = msg;
|
||||
};
|
||||
|
||||
await serverTransport.send(message);
|
||||
expect(receivedMessage).toEqual(message);
|
||||
});
|
||||
|
||||
test('should handle close', async () => {
|
||||
let clientClosed = false;
|
||||
let serverClosed = false;
|
||||
|
||||
clientTransport.onclose = () => {
|
||||
clientClosed = true;
|
||||
};
|
||||
|
||||
serverTransport.onclose = () => {
|
||||
serverClosed = true;
|
||||
};
|
||||
|
||||
await clientTransport.close();
|
||||
expect(clientClosed).toBe(true);
|
||||
expect(serverClosed).toBe(true);
|
||||
});
|
||||
|
||||
test('should throw error when sending after close', async () => {
|
||||
await clientTransport.close();
|
||||
await expect(clientTransport.send({ jsonrpc: '2.0', method: 'test', id: 1 })).rejects.toThrow('Not connected');
|
||||
});
|
||||
|
||||
test('should queue messages sent before start', async () => {
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'test',
|
||||
id: 1
|
||||
};
|
||||
|
||||
let receivedMessage: JSONRPCMessage | undefined;
|
||||
serverTransport.onmessage = msg => {
|
||||
receivedMessage = msg;
|
||||
};
|
||||
|
||||
await clientTransport.send(message);
|
||||
await serverTransport.start();
|
||||
expect(receivedMessage).toEqual(message);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import path from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { Client } from '../client/index.js';
|
||||
import { StdioClientTransport } from '../client/stdio.js';
|
||||
import { Server } from '../server/index.js';
|
||||
import { StdioServerTransport } from '../server/stdio.js';
|
||||
import { LoggingMessageNotificationSchema } from '../types.js';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__');
|
||||
|
||||
describe('Process cleanup', () => {
|
||||
vi.setConfig({ testTimeout: 5000 }); // 5 second timeout
|
||||
|
||||
it('server should exit cleanly after closing transport', async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
}
|
||||
);
|
||||
|
||||
const mockReadable = new Readable({
|
||||
read() {
|
||||
this.push(null); // signal EOF
|
||||
}
|
||||
}),
|
||||
mockWritable = new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
// Attach mock streams to process for the server transport
|
||||
const transport = new StdioServerTransport(mockReadable, mockWritable);
|
||||
await server.connect(transport);
|
||||
|
||||
// Close the transport
|
||||
await transport.close();
|
||||
|
||||
// ensure a proper disposal mock streams
|
||||
mockReadable.destroy();
|
||||
mockWritable.destroy();
|
||||
|
||||
// If we reach here without hanging, the test passes
|
||||
// The test runner will fail if the process hangs
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('onclose should be called exactly once', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['--import', 'tsx', 'testServer.ts'],
|
||||
cwd: FIXTURES_DIR
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
let onCloseWasCalled = 0;
|
||||
client.onclose = () => {
|
||||
onCloseWasCalled++;
|
||||
};
|
||||
|
||||
await client.close();
|
||||
|
||||
// A short delay to allow the close event to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(onCloseWasCalled).toBe(1);
|
||||
});
|
||||
|
||||
it('should exit cleanly for a server that hangs', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['--import', 'tsx', 'serverThatHangs.ts'],
|
||||
cwd: FIXTURES_DIR
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
await client.setLoggingLevel('debug');
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
console.debug('server log: ' + notification.params.data);
|
||||
});
|
||||
const serverPid = transport.pid!;
|
||||
|
||||
await client.close();
|
||||
|
||||
// A short delay to allow the close event to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
try {
|
||||
process.kill(serverPid, 9);
|
||||
throw new Error('Expected server to be dead but it is alive');
|
||||
} catch (err: unknown) {
|
||||
// 'ESRCH' the process doesn't exist
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') {
|
||||
// success
|
||||
} else throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,360 +0,0 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client } from '../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
|
||||
import { McpServer } from '../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
LATEST_PROTOCOL_VERSION
|
||||
} from '../types.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
describe('Streamable HTTP Transport Session Management', () => {
|
||||
// Function to set up the server with optional session management
|
||||
async function setupServer(withSessionManagement: boolean) {
|
||||
const server: Server = createServer();
|
||||
const mcpServer = new McpServer(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add a simple resource
|
||||
mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: '/test',
|
||||
text: 'This is a test resource content'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
mcpServer.prompt('test-prompt', 'A test prompt', async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'This is a test prompt'
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
mcpServer.tool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{
|
||||
name: z.string().describe('Name to greet').default('World')
|
||||
},
|
||||
async ({ name }) => {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create transport with or without session management
|
||||
const serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: withSessionManagement
|
||||
? () => randomUUID() // With session management, generate UUID
|
||||
: undefined // Without session management, return undefined
|
||||
});
|
||||
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
server.on('request', async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
|
||||
return { server, mcpServer, serverTransport, baseUrl };
|
||||
}
|
||||
|
||||
describe('Stateless Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(false);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should support multiple client connections', async () => {
|
||||
// Create and connect a client
|
||||
const client1 = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport1 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client1.connect(transport1);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport1.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
await client1.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
const client2 = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport2 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client2.connect(transport2);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport2.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
await client2.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
});
|
||||
it('should operate without session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'greet'
|
||||
})
|
||||
);
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request(
|
||||
{
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
},
|
||||
ListResourcesResultSchema
|
||||
);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request(
|
||||
{
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
},
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
})
|
||||
);
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateless Transport'
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema
|
||||
);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should set protocol version after connecting', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Verify protocol version is not set before connecting
|
||||
expect(transport.protocolVersion).toBeUndefined();
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify protocol version is set after connecting
|
||||
expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stateful Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(true);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should operate with session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that a session ID was set
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
expect(typeof transport.sessionId).toBe('string');
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'greet'
|
||||
})
|
||||
);
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request(
|
||||
{
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
},
|
||||
ListResourcesResultSchema
|
||||
);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request(
|
||||
{
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
},
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
})
|
||||
);
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateful Transport'
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema
|
||||
);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,269 +0,0 @@
|
|||
import { createServer, type Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Client } from '../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
|
||||
import { McpServer } from '../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
|
||||
import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js';
|
||||
import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js';
|
||||
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
describe('Transport resumability', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
let eventStore: InMemoryEventStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create event store for resumability
|
||||
eventStore = new InMemoryEventStore();
|
||||
|
||||
// Create a simple MCP server
|
||||
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
|
||||
|
||||
// Add a simple notification tool that completes quickly
|
||||
mcpServer.tool(
|
||||
'send-notification',
|
||||
'Sends a single notification',
|
||||
{
|
||||
message: z.string().describe('Message to send').default('Test notification')
|
||||
},
|
||||
async ({ message }, { sendNotification }) => {
|
||||
// Send notification immediately
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
data: message
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Notification sent' }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Add a long-running tool that sends multiple notifications
|
||||
mcpServer.tool(
|
||||
'run-notifications',
|
||||
'Sends multiple notifications over time',
|
||||
{
|
||||
count: z.number().describe('Number of notifications to send').default(10),
|
||||
interval: z.number().describe('Interval between notifications in ms').default(50)
|
||||
},
|
||||
async ({ count, interval }, { sendNotification }) => {
|
||||
// Send notifications at specified intervals
|
||||
for (let i = 0; i < count; i++) {
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
data: `Notification ${i + 1} of ${count}`
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the specified interval before sending next notification
|
||||
if (i < count - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Sent ${count} notifications` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create a transport with the event store
|
||||
serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore
|
||||
});
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
// Create and start an HTTP server
|
||||
server = createServer(async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should store session ID when client connects', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify session ID was generated
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should have session ID functionality', async () => {
|
||||
// The ability to store a session ID when connecting
|
||||
const client = new Client({
|
||||
name: 'test-client-reconnection',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Make sure the client can connect and get a session ID
|
||||
await client.connect(transport);
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
// This test demonstrates the capability to resume long-running tools
|
||||
// across client disconnection/reconnection
|
||||
it('should resume long-running notifications with lastEventId', async () => {
|
||||
// Create unique client ID for this test
|
||||
const clientTitle = 'test-client-long-running';
|
||||
const notifications = [];
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
// Create first client
|
||||
const client1 = new Client({
|
||||
title: clientTitle,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Set up notification handler for first client
|
||||
client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
notifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect first client
|
||||
const transport1 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client1.connect(transport1);
|
||||
const sessionId = transport1.sessionId;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Start a long-running notification stream with tracking of lastEventId
|
||||
const onLastEventIdUpdate = vi.fn((eventId: string) => {
|
||||
lastEventId = eventId;
|
||||
});
|
||||
expect(lastEventId).toBeUndefined();
|
||||
// Start the notification tool with event tracking using request
|
||||
const toolPromise = client1.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'run-notifications',
|
||||
arguments: {
|
||||
count: 3,
|
||||
interval: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{
|
||||
resumptionToken: lastEventId,
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
}
|
||||
);
|
||||
|
||||
// Fix for node 18 test failures, allow some time for notifications to arrive
|
||||
const maxWaitTime = 2000; // 2 seconds max wait
|
||||
const pollInterval = 10; // Check every 10ms
|
||||
const startTime = Date.now();
|
||||
while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) {
|
||||
// Wait for some notifications to arrive (not all) - shorter wait time
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// Verify we received some notifications and lastEventId was updated
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
expect(notifications.length).toBeLessThan(4);
|
||||
expect(onLastEventIdUpdate).toHaveBeenCalled();
|
||||
expect(lastEventId).toBeDefined();
|
||||
|
||||
// Disconnect first client without waiting for completion
|
||||
// When we close the connection, it will cause a ConnectionClosed error for
|
||||
// any in-progress requests, which is expected behavior
|
||||
await transport1.close();
|
||||
// Save the promise so we can catch it after closing
|
||||
const catchPromise = toolPromise.catch(err => {
|
||||
// This error is expected - the connection was intentionally closed
|
||||
if (err?.code !== -32000) {
|
||||
// ConnectionClosed error code
|
||||
console.error('Unexpected error type during transport close:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Add a short delay to ensure clean disconnect before reconnecting
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Wait for the rejection to be handled
|
||||
await catchPromise;
|
||||
|
||||
// Create second client with same client ID
|
||||
const client2 = new Client({
|
||||
title: clientTitle,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Track replayed notifications separately
|
||||
const replayedNotifications: unknown[] = [];
|
||||
client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
replayedNotifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect second client with same session ID
|
||||
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
|
||||
sessionId
|
||||
});
|
||||
await client2.connect(transport2);
|
||||
|
||||
// Resume GET SSE stream with Last-Event-ID to replay missed events
|
||||
// Per spec, resumption uses GET with Last-Event-ID header
|
||||
await transport2.resumeStream(lastEventId!, { onresumptiontoken: onLastEventIdUpdate });
|
||||
|
||||
// Wait for replayed events to arrive via SSE
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify the test infrastructure worked - we received notifications in first session
|
||||
// and captured the lastEventId for potential replay
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
expect(lastEventId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport2.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue