mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-28 21:26:13 +01:00
* revocation metadata fields * store metadata * get client info and meta * revoke oauth tokens * delete flow * uninstall oauth mcp * revoke button * revoke oauth refactor, add comments, test * adjust for clarity * test deleteFlow * handle metadata type * no mutation * adjust for clarity * styling * restructure for clarity * move token-specific stuff * use mcpmanager's oauth servers * fix typo * fix addressing of oauth prop * log prefix * remove debug log
193 lines
5.6 KiB
TypeScript
193 lines
5.6 KiB
TypeScript
import { MCPTokenStorage } from '~/mcp/oauth/tokens';
|
|
import { decryptV2 } from '~/crypto';
|
|
import type { TokenMethods, IToken } from '@librechat/data-schemas';
|
|
import { Types } from 'mongoose';
|
|
|
|
jest.mock('~/crypto', () => ({
|
|
decryptV2: jest.fn(),
|
|
}));
|
|
|
|
const mockDecryptV2 = decryptV2 as jest.MockedFunction<typeof decryptV2>;
|
|
|
|
describe('MCPTokenStorage', () => {
|
|
afterAll(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('deleteUserTokens', () => {
|
|
const userId = '000000001111111122222222';
|
|
const serverName = 'test-server';
|
|
let mockDeleteToken: jest.MockedFunction<
|
|
(filter: { userId: string; type: string; identifier: string }) => Promise<void>
|
|
>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockDeleteToken = jest.fn().mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('should delete all OAuth-related tokens for a user and server', async () => {
|
|
await MCPTokenStorage.deleteUserTokens({
|
|
userId,
|
|
serverName,
|
|
deleteToken: mockDeleteToken,
|
|
});
|
|
|
|
// Verify all three token types were deleted with correct identifiers
|
|
expect(mockDeleteToken).toHaveBeenCalledTimes(3);
|
|
expect(mockDeleteToken).toHaveBeenCalledWith({
|
|
userId,
|
|
type: 'mcp_oauth_client',
|
|
identifier: `mcp:${serverName}:client`,
|
|
});
|
|
expect(mockDeleteToken).toHaveBeenCalledWith({
|
|
userId,
|
|
type: 'mcp_oauth',
|
|
identifier: `mcp:${serverName}`,
|
|
});
|
|
expect(mockDeleteToken).toHaveBeenCalledWith({
|
|
userId,
|
|
type: 'mcp_oauth_refresh',
|
|
identifier: `mcp:${serverName}:refresh`,
|
|
});
|
|
});
|
|
|
|
it('should handle deletion errors gracefully', async () => {
|
|
mockDeleteToken.mockRejectedValueOnce(new Error('Deletion failed'));
|
|
|
|
await expect(
|
|
MCPTokenStorage.deleteUserTokens({
|
|
userId,
|
|
serverName,
|
|
deleteToken: mockDeleteToken,
|
|
}),
|
|
).rejects.toThrow('Deletion failed');
|
|
|
|
expect(mockDeleteToken).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('getClientInfoAndMetadata', () => {
|
|
const userId = '000000001111111122222222';
|
|
const serverName = 'test-server';
|
|
const identifier = `mcp:${serverName}`;
|
|
let mockFindToken: jest.MockedFunction<TokenMethods['findToken']>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockFindToken = jest.fn();
|
|
});
|
|
|
|
it('should return null when no client info token exists', async () => {
|
|
mockFindToken.mockResolvedValue(null);
|
|
|
|
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
|
userId,
|
|
serverName,
|
|
findToken: mockFindToken,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockFindToken).toHaveBeenCalledWith({
|
|
userId,
|
|
type: 'mcp_oauth_client',
|
|
identifier: `${identifier}:client`,
|
|
});
|
|
});
|
|
|
|
it('should return client info and metadata when token exists', async () => {
|
|
const clientInfo = {
|
|
client_id: 'test-client-id',
|
|
client_secret: 'test-secret',
|
|
};
|
|
|
|
const metadata = new Map([
|
|
['serverUrl', 'https://test.example.com'],
|
|
['state', 'test-state'],
|
|
]);
|
|
|
|
const mockToken: IToken = {
|
|
userId: new Types.ObjectId(userId),
|
|
type: 'mcp_oauth_client',
|
|
identifier: `${identifier}:client`,
|
|
token: 'encrypted-token',
|
|
metadata,
|
|
} as IToken;
|
|
|
|
mockFindToken.mockResolvedValue(mockToken);
|
|
mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
|
|
|
|
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
|
userId,
|
|
serverName,
|
|
findToken: mockFindToken,
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.clientInfo).toEqual(clientInfo);
|
|
expect(result?.clientMetadata).toEqual({
|
|
serverUrl: 'https://test.example.com',
|
|
state: 'test-state',
|
|
});
|
|
expect(mockDecryptV2).toHaveBeenCalledWith('encrypted-token');
|
|
});
|
|
|
|
it('should handle empty metadata', async () => {
|
|
const clientInfo = {
|
|
client_id: 'test-client-id',
|
|
};
|
|
|
|
const mockToken: IToken = {
|
|
userId: new Types.ObjectId(userId),
|
|
type: 'mcp_oauth_client',
|
|
identifier: `${identifier}:client`,
|
|
token: 'encrypted-token',
|
|
} as IToken;
|
|
|
|
mockFindToken.mockResolvedValue(mockToken);
|
|
mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
|
|
|
|
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
|
userId,
|
|
serverName,
|
|
findToken: mockFindToken,
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.clientInfo).toEqual(clientInfo);
|
|
expect(result?.clientMetadata).toEqual({});
|
|
});
|
|
|
|
it('should handle metadata as plain object', async () => {
|
|
const clientInfo = {
|
|
client_id: 'test-client-id',
|
|
};
|
|
|
|
const metadata = {
|
|
serverUrl: 'https://test.example.com',
|
|
state: 'test-state',
|
|
};
|
|
|
|
const mockToken: IToken = {
|
|
userId: new Types.ObjectId(userId),
|
|
type: 'mcp_oauth_client',
|
|
identifier: `${identifier}:client`,
|
|
token: 'encrypted-token',
|
|
metadata: metadata as unknown, // runtime check
|
|
} as IToken;
|
|
|
|
mockFindToken.mockResolvedValue(mockToken);
|
|
mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
|
|
|
|
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
|
userId,
|
|
serverName,
|
|
findToken: mockFindToken,
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.clientInfo).toEqual(clientInfo);
|
|
expect(result?.clientMetadata).toEqual(metadata);
|
|
});
|
|
});
|
|
});
|