Relocate tests under /test (#1220)
This commit is contained in:
parent
7655eeb51c
commit
5a9de1c638
7 changed files with 630 additions and 524 deletions
90
test/shared/auth-utils.test.ts
Normal file
90
test/shared/auth-utils.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { resourceUrlFromServerUrl, checkResourceAllowed } from '../../src/shared/auth-utils.js';
|
||||
|
||||
describe('auth-utils', () => {
|
||||
describe('resourceUrlFromServerUrl', () => {
|
||||
it('should remove fragments', () => {
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path');
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/');
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe(
|
||||
'https://example.com/path?query=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return URL unchanged if no fragment', () => {
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/');
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path');
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1');
|
||||
});
|
||||
|
||||
it('should keep everything else unchanged', () => {
|
||||
// Case sensitivity preserved
|
||||
expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH');
|
||||
// Ports preserved
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path');
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path');
|
||||
// Query parameters preserved
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe(
|
||||
'https://example.com/?foo=bar&baz=qux'
|
||||
);
|
||||
// Trailing slashes preserved
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/');
|
||||
expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resourceMatches', () => {
|
||||
it('should match identical URLs', () => {
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })
|
||||
).toBe(true);
|
||||
expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should not match URLs with different paths', () => {
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match URLs with different domains', () => {
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match URLs with different ports', () => {
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match URLs where one path is a sub-path of another', () => {
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
checkResourceAllowed({
|
||||
requestedResource: 'https://example.com/folder',
|
||||
configuredResource: 'https://example.com/folder/subfolder'
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle trailing slashes vs no trailing slashes', () => {
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
122
test/shared/auth.test.ts
Normal file
122
test/shared/auth.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import {
|
||||
SafeUrlSchema,
|
||||
OAuthMetadataSchema,
|
||||
OpenIdProviderMetadataSchema,
|
||||
OAuthClientMetadataSchema,
|
||||
OptionalSafeUrlSchema
|
||||
} from '../../src/shared/auth.js';
|
||||
|
||||
describe('SafeUrlSchema', () => {
|
||||
it('accepts valid HTTPS URLs', () => {
|
||||
expect(SafeUrlSchema.parse('https://example.com')).toBe('https://example.com');
|
||||
expect(SafeUrlSchema.parse('https://auth.example.com/oauth/authorize')).toBe('https://auth.example.com/oauth/authorize');
|
||||
});
|
||||
|
||||
it('accepts valid HTTP URLs', () => {
|
||||
expect(SafeUrlSchema.parse('http://localhost:3000')).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('rejects javascript: scheme URLs', () => {
|
||||
expect(() => SafeUrlSchema.parse('javascript:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
|
||||
expect(() => SafeUrlSchema.parse('JAVASCRIPT:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
|
||||
});
|
||||
|
||||
it('rejects invalid URLs', () => {
|
||||
expect(() => SafeUrlSchema.parse('not-a-url')).toThrow();
|
||||
expect(() => SafeUrlSchema.parse('')).toThrow();
|
||||
});
|
||||
|
||||
it('works with safeParse', () => {
|
||||
expect(() => SafeUrlSchema.safeParse('not-a-url')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OptionalSafeUrlSchema', () => {
|
||||
it('accepts empty string and transforms it to undefined', () => {
|
||||
expect(OptionalSafeUrlSchema.parse('')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuthMetadataSchema', () => {
|
||||
it('validates complete OAuth metadata', () => {
|
||||
const metadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||
response_types_supported: ['code'],
|
||||
scopes_supported: ['read', 'write']
|
||||
};
|
||||
|
||||
expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects metadata with javascript: URLs', () => {
|
||||
const metadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'javascript:alert(1)',
|
||||
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
|
||||
expect(() => OAuthMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
|
||||
});
|
||||
|
||||
it('requires mandatory fields', () => {
|
||||
const incompleteMetadata = {
|
||||
issuer: 'https://auth.example.com'
|
||||
};
|
||||
|
||||
expect(() => OAuthMetadataSchema.parse(incompleteMetadata)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenIdProviderMetadataSchema', () => {
|
||||
it('validates complete OpenID Provider metadata', () => {
|
||||
const metadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256']
|
||||
};
|
||||
|
||||
expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects metadata with javascript: in jwks_uri', () => {
|
||||
const metadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||
jwks_uri: 'javascript:alert(1)',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256']
|
||||
};
|
||||
|
||||
expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuthClientMetadataSchema', () => {
|
||||
it('validates client metadata with safe URLs', () => {
|
||||
const metadata = {
|
||||
redirect_uris: ['https://app.example.com/callback'],
|
||||
client_name: 'Test App',
|
||||
client_uri: 'https://app.example.com'
|
||||
};
|
||||
|
||||
expect(() => OAuthClientMetadataSchema.parse(metadata)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects client metadata with javascript: redirect URIs', () => {
|
||||
const metadata = {
|
||||
redirect_uris: ['javascript:alert(1)'],
|
||||
client_name: 'Test App'
|
||||
};
|
||||
|
||||
expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
|
||||
});
|
||||
});
|
||||
189
test/shared/protocol-transport-handling.test.ts
Normal file
189
test/shared/protocol-transport-handling.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { describe, expect, test, beforeEach } from 'vitest';
|
||||
import { Protocol } from '../../src/shared/protocol.js';
|
||||
import { Transport } from '../../src/shared/transport.js';
|
||||
import { Request, Notification, Result, JSONRPCMessage } from '../../src/types.js';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// Mock Transport class
|
||||
class MockTransport implements Transport {
|
||||
id: string;
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: unknown) => void;
|
||||
sentMessages: JSONRPCMessage[] = [];
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.sentMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Protocol transport handling bug', () => {
|
||||
let protocol: Protocol<Request, Notification, Result>;
|
||||
let transportA: MockTransport;
|
||||
let transportB: MockTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
protocol = new (class extends Protocol<Request, Notification, Result> {
|
||||
protected assertCapabilityForMethod(): void {}
|
||||
protected assertNotificationCapability(): void {}
|
||||
protected assertRequestHandlerCapability(): void {}
|
||||
protected assertTaskCapability(): void {}
|
||||
protected assertTaskHandlerCapability(): void {}
|
||||
})();
|
||||
|
||||
transportA = new MockTransport('A');
|
||||
transportB = new MockTransport('B');
|
||||
});
|
||||
|
||||
test('should send response to the correct transport when multiple clients are connected', async () => {
|
||||
// Set up a request handler that simulates processing time
|
||||
let resolveHandler: (value: Result) => void;
|
||||
const handlerPromise = new Promise<Result>(resolve => {
|
||||
resolveHandler = resolve;
|
||||
});
|
||||
|
||||
const TestRequestSchema = z.object({
|
||||
method: z.literal('test/method'),
|
||||
params: z
|
||||
.object({
|
||||
from: z.string()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
protocol.setRequestHandler(TestRequestSchema, async request => {
|
||||
console.log(`Processing request from ${request.params?.from}`);
|
||||
return handlerPromise;
|
||||
});
|
||||
|
||||
// Client A connects and sends a request
|
||||
await protocol.connect(transportA);
|
||||
|
||||
const requestFromA = {
|
||||
jsonrpc: '2.0' as const,
|
||||
method: 'test/method',
|
||||
params: { from: 'clientA' },
|
||||
id: 1
|
||||
};
|
||||
|
||||
// Simulate client A sending a request
|
||||
transportA.onmessage?.(requestFromA);
|
||||
|
||||
// While A's request is being processed, client B connects
|
||||
// This overwrites the transport reference in the protocol
|
||||
await protocol.connect(transportB);
|
||||
|
||||
const requestFromB = {
|
||||
jsonrpc: '2.0' as const,
|
||||
method: 'test/method',
|
||||
params: { from: 'clientB' },
|
||||
id: 2
|
||||
};
|
||||
|
||||
// Client B sends its own request
|
||||
transportB.onmessage?.(requestFromB);
|
||||
|
||||
// Now complete A's request
|
||||
resolveHandler!({ data: 'responseForA' } as Result);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Check where the responses went
|
||||
console.log('Transport A received:', transportA.sentMessages);
|
||||
console.log('Transport B received:', transportB.sentMessages);
|
||||
|
||||
// FIXED: Each transport now receives its own response
|
||||
|
||||
// Transport A should receive response for request ID 1
|
||||
expect(transportA.sentMessages.length).toBe(1);
|
||||
expect(transportA.sentMessages[0]).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: { data: 'responseForA' }
|
||||
});
|
||||
|
||||
// Transport B should only receive its own response (when implemented)
|
||||
expect(transportB.sentMessages.length).toBe(1);
|
||||
expect(transportB.sentMessages[0]).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
result: { data: 'responseForA' } // Same handler result in this test
|
||||
});
|
||||
});
|
||||
|
||||
test('demonstrates the timing issue with multiple rapid connections', async () => {
|
||||
const delays: number[] = [];
|
||||
const results: { transport: string; response: JSONRPCMessage[] }[] = [];
|
||||
|
||||
const DelayedRequestSchema = z.object({
|
||||
method: z.literal('test/delayed'),
|
||||
params: z
|
||||
.object({
|
||||
delay: z.number(),
|
||||
client: z.string()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
// Set up handler with variable delay
|
||||
protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => {
|
||||
const delay = request.params?.delay || 0;
|
||||
delays.push(delay);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
return {
|
||||
processedBy: `handler-${extra.requestId}`,
|
||||
delay: delay
|
||||
} as Result;
|
||||
});
|
||||
|
||||
// Rapid succession of connections and requests
|
||||
await protocol.connect(transportA);
|
||||
transportA.onmessage?.({
|
||||
jsonrpc: '2.0' as const,
|
||||
method: 'test/delayed',
|
||||
params: { delay: 50, client: 'A' },
|
||||
id: 1
|
||||
});
|
||||
|
||||
// Connect B while A is processing
|
||||
setTimeout(async () => {
|
||||
await protocol.connect(transportB);
|
||||
transportB.onmessage?.({
|
||||
jsonrpc: '2.0' as const,
|
||||
method: 'test/delayed',
|
||||
params: { delay: 10, client: 'B' },
|
||||
id: 2
|
||||
});
|
||||
}, 10);
|
||||
|
||||
// Wait for all processing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Collect results
|
||||
if (transportA.sentMessages.length > 0) {
|
||||
results.push({ transport: 'A', response: transportA.sentMessages });
|
||||
}
|
||||
if (transportB.sentMessages.length > 0) {
|
||||
results.push({ transport: 'B', response: transportB.sentMessages });
|
||||
}
|
||||
|
||||
console.log('Timing test results:', results);
|
||||
|
||||
// FIXED: Each transport receives its own responses
|
||||
expect(transportA.sentMessages.length).toBe(1);
|
||||
expect(transportB.sentMessages.length).toBe(1);
|
||||
});
|
||||
});
|
||||
5558
test/shared/protocol.test.ts
Normal file
5558
test/shared/protocol.test.ts
Normal file
File diff suppressed because it is too large
Load diff
35
test/shared/stdio.test.ts
Normal file
35
test/shared/stdio.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { JSONRPCMessage } from '../../src/types.js';
|
||||
import { ReadBuffer } from '../../src/shared/stdio.js';
|
||||
|
||||
const testMessage: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'foobar'
|
||||
};
|
||||
|
||||
test('should have no messages after initialization', () => {
|
||||
const readBuffer = new ReadBuffer();
|
||||
expect(readBuffer.readMessage()).toBeNull();
|
||||
});
|
||||
|
||||
test('should only yield a message after a newline', () => {
|
||||
const readBuffer = new ReadBuffer();
|
||||
|
||||
readBuffer.append(Buffer.from(JSON.stringify(testMessage)));
|
||||
expect(readBuffer.readMessage()).toBeNull();
|
||||
|
||||
readBuffer.append(Buffer.from('\n'));
|
||||
expect(readBuffer.readMessage()).toEqual(testMessage);
|
||||
expect(readBuffer.readMessage()).toBeNull();
|
||||
});
|
||||
|
||||
test('should be reusable after clearing', () => {
|
||||
const readBuffer = new ReadBuffer();
|
||||
|
||||
readBuffer.append(Buffer.from('foobar'));
|
||||
readBuffer.clear();
|
||||
expect(readBuffer.readMessage()).toBeNull();
|
||||
|
||||
readBuffer.append(Buffer.from(JSON.stringify(testMessage)));
|
||||
readBuffer.append(Buffer.from('\n'));
|
||||
expect(readBuffer.readMessage()).toEqual(testMessage);
|
||||
});
|
||||
128
test/shared/toolNameValidation.test.ts
Normal file
128
test/shared/toolNameValidation.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { validateToolName, validateAndWarnToolName, issueToolNameWarning } from '../../src/shared/toolNameValidation.js';
|
||||
import { vi, MockInstance } from 'vitest';
|
||||
|
||||
// Spy on console.warn to capture output
|
||||
let warnSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('validateToolName', () => {
|
||||
describe('valid tool names', () => {
|
||||
test.each`
|
||||
description | toolName
|
||||
${'simple alphanumeric names'} | ${'getUser'}
|
||||
${'names with underscores'} | ${'get_user_profile'}
|
||||
${'names with dashes'} | ${'user-profile-update'}
|
||||
${'names with dots'} | ${'admin.tools.list'}
|
||||
${'mixed character names'} | ${'DATA_EXPORT_v2.1'}
|
||||
${'single character names'} | ${'a'}
|
||||
${'128 character names'} | ${'a'.repeat(128)}
|
||||
`('should accept $description', ({ toolName }) => {
|
||||
const result = validateToolName(toolName);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid tool names', () => {
|
||||
test.each`
|
||||
description | toolName | expectedWarning
|
||||
${'empty names'} | ${''} | ${'Tool name cannot be empty'}
|
||||
${'names longer than 128 characters'} | ${'a'.repeat(129)} | ${'Tool name exceeds maximum length of 128 characters (current: 129)'}
|
||||
${'names with spaces'} | ${'get user profile'} | ${'Tool name contains invalid characters: " "'}
|
||||
${'names with commas'} | ${'get,user,profile'} | ${'Tool name contains invalid characters: ","'}
|
||||
${'names with forward slashes'} | ${'user/profile/update'} | ${'Tool name contains invalid characters: "/"'}
|
||||
${'names with other special chars'} | ${'user@domain.com'} | ${'Tool name contains invalid characters: "@"'}
|
||||
${'names with multiple invalid chars'} | ${'user name@domain,com'} | ${'Tool name contains invalid characters: " ", "@", ","'}
|
||||
${'names with unicode characters'} | ${'user-ñame'} | ${'Tool name contains invalid characters: "ñ"'}
|
||||
`('should reject $description', ({ toolName, expectedWarning }) => {
|
||||
const result = validateToolName(toolName);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.warnings).toContain(expectedWarning);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warnings for potentially problematic patterns', () => {
|
||||
test.each`
|
||||
description | toolName | expectedWarning | shouldBeValid
|
||||
${'names with spaces'} | ${'get user profile'} | ${'Tool name contains spaces, which may cause parsing issues'} | ${false}
|
||||
${'names with commas'} | ${'get,user,profile'} | ${'Tool name contains commas, which may cause parsing issues'} | ${false}
|
||||
${'names starting with dash'} | ${'-get-user'} | ${'Tool name starts or ends with a dash, which may cause parsing issues in some contexts'} | ${true}
|
||||
${'names ending with dash'} | ${'get-user-'} | ${'Tool name starts or ends with a dash, which may cause parsing issues in some contexts'} | ${true}
|
||||
${'names starting with dot'} | ${'.get.user'} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'} | ${true}
|
||||
${'names ending with dot'} | ${'get.user.'} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'} | ${true}
|
||||
${'names with leading and trailing dots'} | ${'.get.user.'} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'} | ${true}
|
||||
`('should warn about $description', ({ toolName, expectedWarning, shouldBeValid }) => {
|
||||
const result = validateToolName(toolName);
|
||||
expect(result.isValid).toBe(shouldBeValid);
|
||||
expect(result.warnings).toContain(expectedWarning);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('issueToolNameWarning', () => {
|
||||
test('should output warnings to console.warn', () => {
|
||||
const warnings = ['Warning 1', 'Warning 2'];
|
||||
issueToolNameWarning('test-tool', warnings);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(6); // Header + 2 warnings + 3 guidance lines
|
||||
const calls = warnSpy.mock.calls.map(call => call.join(' '));
|
||||
expect(calls[0]).toContain('Tool name validation warning for "test-tool"');
|
||||
expect(calls[1]).toContain('- Warning 1');
|
||||
expect(calls[2]).toContain('- Warning 2');
|
||||
expect(calls[3]).toContain('Tool registration will proceed, but this may cause compatibility issues.');
|
||||
expect(calls[4]).toContain('Consider updating the tool name');
|
||||
expect(calls[5]).toContain('See SEP: Specify Format for Tool Names');
|
||||
});
|
||||
|
||||
test('should handle empty warnings array', () => {
|
||||
issueToolNameWarning('test-tool', []);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAndWarnToolName', () => {
|
||||
test.each`
|
||||
description | toolName | expectedResult | shouldWarn
|
||||
${'valid names with warnings'} | ${'-get-user-'} | ${true} | ${true}
|
||||
${'completely valid names'} | ${'get-user-profile'} | ${true} | ${false}
|
||||
${'invalid names with spaces'} | ${'get user profile'} | ${false} | ${true}
|
||||
${'empty names'} | ${''} | ${false} | ${true}
|
||||
${'names exceeding length limit'} | ${'a'.repeat(129)} | ${false} | ${true}
|
||||
`('should handle $description', ({ toolName, expectedResult, shouldWarn }) => {
|
||||
const result = validateAndWarnToolName(toolName);
|
||||
expect(result).toBe(expectedResult);
|
||||
|
||||
if (shouldWarn) {
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test('should include space warning for invalid names with spaces', () => {
|
||||
validateAndWarnToolName('get user profile');
|
||||
const warningCalls = warnSpy.mock.calls.map(call => call.join(' '));
|
||||
expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and robustness', () => {
|
||||
test.each`
|
||||
description | toolName | shouldBeValid | expectedWarning
|
||||
${'names with only dots'} | ${'...'} | ${true} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'}
|
||||
${'names with only dashes'} | ${'---'} | ${true} | ${'Tool name starts or ends with a dash, which may cause parsing issues in some contexts'}
|
||||
${'names with only forward slashes'} | ${'///'} | ${false} | ${'Tool name contains invalid characters: "/"'}
|
||||
${'names with mixed valid/invalid chars'} | ${'user@name123'} | ${false} | ${'Tool name contains invalid characters: "@"'}
|
||||
`('should handle $description', ({ toolName, shouldBeValid, expectedWarning }) => {
|
||||
const result = validateToolName(toolName);
|
||||
expect(result.isValid).toBe(shouldBeValid);
|
||||
expect(result.warnings).toContain(expectedWarning);
|
||||
});
|
||||
});
|
||||
288
test/shared/uriTemplate.test.ts
Normal file
288
test/shared/uriTemplate.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { UriTemplate } from '../../src/shared/uriTemplate.js';
|
||||
|
||||
describe('UriTemplate', () => {
|
||||
describe('isTemplate', () => {
|
||||
it('should return true for strings containing template expressions', () => {
|
||||
expect(UriTemplate.isTemplate('{foo}')).toBe(true);
|
||||
expect(UriTemplate.isTemplate('/users/{id}')).toBe(true);
|
||||
expect(UriTemplate.isTemplate('http://example.com/{path}/{file}')).toBe(true);
|
||||
expect(UriTemplate.isTemplate('/search{?q,limit}')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for strings without template expressions', () => {
|
||||
expect(UriTemplate.isTemplate('')).toBe(false);
|
||||
expect(UriTemplate.isTemplate('plain string')).toBe(false);
|
||||
expect(UriTemplate.isTemplate('http://example.com/foo/bar')).toBe(false);
|
||||
expect(UriTemplate.isTemplate('{}')).toBe(false); // Empty braces don't count
|
||||
expect(UriTemplate.isTemplate('{ }')).toBe(false); // Just whitespace doesn't count
|
||||
});
|
||||
});
|
||||
|
||||
describe('simple string expansion', () => {
|
||||
it('should expand simple string variables', () => {
|
||||
const template = new UriTemplate('http://example.com/users/{username}');
|
||||
expect(template.expand({ username: 'fred' })).toBe('http://example.com/users/fred');
|
||||
expect(template.variableNames).toEqual(['username']);
|
||||
});
|
||||
|
||||
it('should handle multiple variables', () => {
|
||||
const template = new UriTemplate('{x,y}');
|
||||
expect(template.expand({ x: '1024', y: '768' })).toBe('1024,768');
|
||||
expect(template.variableNames).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
it('should encode reserved characters', () => {
|
||||
const template = new UriTemplate('{var}');
|
||||
expect(template.expand({ var: 'value with spaces' })).toBe('value%20with%20spaces');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved expansion', () => {
|
||||
it('should not encode reserved characters with + operator', () => {
|
||||
const template = new UriTemplate('{+path}/here');
|
||||
expect(template.expand({ path: '/foo/bar' })).toBe('/foo/bar/here');
|
||||
expect(template.variableNames).toEqual(['path']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fragment expansion', () => {
|
||||
it('should add # prefix and not encode reserved chars', () => {
|
||||
const template = new UriTemplate('X{#var}');
|
||||
expect(template.expand({ var: '/test' })).toBe('X#/test');
|
||||
expect(template.variableNames).toEqual(['var']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label expansion', () => {
|
||||
it('should add . prefix', () => {
|
||||
const template = new UriTemplate('X{.var}');
|
||||
expect(template.expand({ var: 'test' })).toBe('X.test');
|
||||
expect(template.variableNames).toEqual(['var']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path expansion', () => {
|
||||
it('should add / prefix', () => {
|
||||
const template = new UriTemplate('X{/var}');
|
||||
expect(template.expand({ var: 'test' })).toBe('X/test');
|
||||
expect(template.variableNames).toEqual(['var']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query expansion', () => {
|
||||
it('should add ? prefix and name=value format', () => {
|
||||
const template = new UriTemplate('X{?var}');
|
||||
expect(template.expand({ var: 'test' })).toBe('X?var=test');
|
||||
expect(template.variableNames).toEqual(['var']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('form continuation expansion', () => {
|
||||
it('should add & prefix and name=value format', () => {
|
||||
const template = new UriTemplate('X{&var}');
|
||||
expect(template.expand({ var: 'test' })).toBe('X&var=test');
|
||||
expect(template.variableNames).toEqual(['var']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matching', () => {
|
||||
it('should match simple strings and extract variables', () => {
|
||||
const template = new UriTemplate('http://example.com/users/{username}');
|
||||
const match = template.match('http://example.com/users/fred');
|
||||
expect(match).toEqual({ username: 'fred' });
|
||||
});
|
||||
|
||||
it('should match multiple variables', () => {
|
||||
const template = new UriTemplate('/users/{username}/posts/{postId}');
|
||||
const match = template.match('/users/fred/posts/123');
|
||||
expect(match).toEqual({ username: 'fred', postId: '123' });
|
||||
});
|
||||
|
||||
it('should return null for non-matching URIs', () => {
|
||||
const template = new UriTemplate('/users/{username}');
|
||||
const match = template.match('/posts/123');
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle exploded arrays', () => {
|
||||
const template = new UriTemplate('{/list*}');
|
||||
const match = template.match('/red,green,blue');
|
||||
expect(match).toEqual({ list: ['red', 'green', 'blue'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty variables', () => {
|
||||
const template = new UriTemplate('{empty}');
|
||||
expect(template.expand({})).toBe('');
|
||||
expect(template.expand({ empty: '' })).toBe('');
|
||||
});
|
||||
|
||||
it('should handle undefined variables', () => {
|
||||
const template = new UriTemplate('{a}{b}{c}');
|
||||
expect(template.expand({ b: '2' })).toBe('2');
|
||||
});
|
||||
|
||||
it('should handle special characters in variable names', () => {
|
||||
const template = new UriTemplate('{$var_name}');
|
||||
expect(template.expand({ $var_name: 'value' })).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex patterns', () => {
|
||||
it('should handle nested path segments', () => {
|
||||
const template = new UriTemplate('/api/{version}/{resource}/{id}');
|
||||
expect(
|
||||
template.expand({
|
||||
version: 'v1',
|
||||
resource: 'users',
|
||||
id: '123'
|
||||
})
|
||||
).toBe('/api/v1/users/123');
|
||||
expect(template.variableNames).toEqual(['version', 'resource', 'id']);
|
||||
});
|
||||
|
||||
it('should handle query parameters with arrays', () => {
|
||||
const template = new UriTemplate('/search{?tags*}');
|
||||
expect(
|
||||
template.expand({
|
||||
tags: ['nodejs', 'typescript', 'testing']
|
||||
})
|
||||
).toBe('/search?tags=nodejs,typescript,testing');
|
||||
expect(template.variableNames).toEqual(['tags']);
|
||||
});
|
||||
|
||||
it('should handle multiple query parameters', () => {
|
||||
const template = new UriTemplate('/search{?q,page,limit}');
|
||||
expect(
|
||||
template.expand({
|
||||
q: 'test',
|
||||
page: '1',
|
||||
limit: '10'
|
||||
})
|
||||
).toBe('/search?q=test&page=1&limit=10');
|
||||
expect(template.variableNames).toEqual(['q', 'page', 'limit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matching complex patterns', () => {
|
||||
it('should match nested path segments', () => {
|
||||
const template = new UriTemplate('/api/{version}/{resource}/{id}');
|
||||
const match = template.match('/api/v1/users/123');
|
||||
expect(match).toEqual({
|
||||
version: 'v1',
|
||||
resource: 'users',
|
||||
id: '123'
|
||||
});
|
||||
expect(template.variableNames).toEqual(['version', 'resource', 'id']);
|
||||
});
|
||||
|
||||
it('should match query parameters', () => {
|
||||
const template = new UriTemplate('/search{?q}');
|
||||
const match = template.match('/search?q=test');
|
||||
expect(match).toEqual({ q: 'test' });
|
||||
expect(template.variableNames).toEqual(['q']);
|
||||
});
|
||||
|
||||
it('should match multiple query parameters', () => {
|
||||
const template = new UriTemplate('/search{?q,page}');
|
||||
const match = template.match('/search?q=test&page=1');
|
||||
expect(match).toEqual({ q: 'test', page: '1' });
|
||||
expect(template.variableNames).toEqual(['q', 'page']);
|
||||
});
|
||||
|
||||
it('should handle partial matches correctly', () => {
|
||||
const template = new UriTemplate('/users/{id}');
|
||||
expect(template.match('/users/123/extra')).toBeNull();
|
||||
expect(template.match('/users')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('security and edge cases', () => {
|
||||
it('should handle extremely long input strings', () => {
|
||||
const longString = 'x'.repeat(100000);
|
||||
const template = new UriTemplate(`/api/{param}`);
|
||||
expect(template.expand({ param: longString })).toBe(`/api/${longString}`);
|
||||
expect(template.match(`/api/${longString}`)).toEqual({ param: longString });
|
||||
});
|
||||
|
||||
it('should handle deeply nested template expressions', () => {
|
||||
const template = new UriTemplate('{a}{b}{c}{d}{e}{f}{g}{h}{i}{j}'.repeat(1000));
|
||||
expect(() =>
|
||||
template.expand({
|
||||
a: '1',
|
||||
b: '2',
|
||||
c: '3',
|
||||
d: '4',
|
||||
e: '5',
|
||||
f: '6',
|
||||
g: '7',
|
||||
h: '8',
|
||||
i: '9',
|
||||
j: '0'
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle malformed template expressions', () => {
|
||||
expect(() => new UriTemplate('{unclosed')).toThrow();
|
||||
expect(() => new UriTemplate('{}')).not.toThrow();
|
||||
expect(() => new UriTemplate('{,}')).not.toThrow();
|
||||
expect(() => new UriTemplate('{a}{')).toThrow();
|
||||
});
|
||||
|
||||
it('should handle pathological regex patterns', () => {
|
||||
const template = new UriTemplate('/api/{param}');
|
||||
// Create a string that could cause catastrophic backtracking
|
||||
const input = '/api/' + 'a'.repeat(100000);
|
||||
expect(() => template.match(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle invalid UTF-8 sequences', () => {
|
||||
const template = new UriTemplate('/api/{param}');
|
||||
const invalidUtf8 = '<27><><EFBFBD>';
|
||||
expect(() => template.expand({ param: invalidUtf8 })).not.toThrow();
|
||||
expect(() => template.match(`/api/${invalidUtf8}`)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle template/URI length mismatches', () => {
|
||||
const template = new UriTemplate('/api/{param}');
|
||||
expect(template.match('/api/')).toBeNull();
|
||||
expect(template.match('/api')).toBeNull();
|
||||
expect(template.match('/api/value/extra')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle repeated operators', () => {
|
||||
const template = new UriTemplate('{?a}{?b}{?c}');
|
||||
expect(template.expand({ a: '1', b: '2', c: '3' })).toBe('?a=1&b=2&c=3');
|
||||
expect(template.variableNames).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should handle overlapping variable names', () => {
|
||||
const template = new UriTemplate('{var}{vara}');
|
||||
expect(template.expand({ var: '1', vara: '2' })).toBe('12');
|
||||
expect(template.variableNames).toEqual(['var', 'vara']);
|
||||
});
|
||||
|
||||
it('should handle empty segments', () => {
|
||||
const template = new UriTemplate('///{a}////{b}////');
|
||||
expect(template.expand({ a: '1', b: '2' })).toBe('///1////2////');
|
||||
expect(template.match('///1////2////')).toEqual({ a: '1', b: '2' });
|
||||
expect(template.variableNames).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should handle maximum template expression limit', () => {
|
||||
// Create a template with many expressions
|
||||
const expressions = Array(10000).fill('{param}').join('');
|
||||
expect(() => new UriTemplate(expressions)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle maximum variable name length', () => {
|
||||
const longName = 'a'.repeat(10000);
|
||||
const template = new UriTemplate(`{${longName}}`);
|
||||
const vars: Record<string, string> = {};
|
||||
vars[longName] = 'value';
|
||||
expect(() => template.expand(vars)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue