mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
* 🧹 fix: Clean Up MCP OAuth State Mappings on Uninstall + Reject Superseded Callbacks Disconnecting an OAuth MCP server deleted its mcp_oauth flows but left the mcp_oauth_state:{state} mappings behind for the full TTL. Because flow ids are deterministic (userId:serverName) and the CSRF token is HMAC(flowId), a stale browser tab's callback could resolve its orphaned state to the NEXT flow for the same server, pass CSRF, burn the fresh flow's one-shot CSRF cookie, and fail the PKCE exchange, sabotaging the legitimate retry. - Add MCPOAuthHandler.deleteFlowAndStateMapping: reads the flow's stored state and deletes the mapping before the flow (mapping-first so a crash between deletes fails closed instead of recreating the orphan) - Route mcp_oauth deletions in clearStoredMCPOAuthState through the helper for both tenant-scoped and legacy flow ids - Reject callbacks whose state does not match the resolved flow's stored state: the only control distinguishing a superseded attempt from the current one on a deterministic flow id Fixes #14534 * fix: gate failFlow on state match in the OAuth error branch (Codex P1) The provider-error branch failed the resolved flow on CSRF/session alone, so a superseded error callback resolved through an orphaned mapping could mark the current flow FAILED. Apply the same stored-state equality gate before failFlow. * fix: leave the flow in place when the state-mapping delete fails (Codex P2) deleteFlow swallows storage errors and returns false, and deleteStateMapping discarded that result, so a failed mapping delete followed by a successful flow delete would silently recreate the orphan. Surface the boolean from deleteStateMapping and throw from deleteFlowAndStateMapping before touching the flow, so the caller's allSettled warn branch fires and the next replacement retries both. * fix: restore the state mapping when the flow delete fails (Codex P2) The inverse partial failure of the round-3 fix: a successful mapping delete followed by a silently failed flow delete left a PENDING flow whose reused authorization URL could never resolve, dead-ending every callback in invalid_state until the flow went stale. Check deleteFlow's result, re-store the mapping on failure, and throw so the caller's allSettled warn branch fires. * fix: never leave a callback-capable flow behind on uninstall (Codex round 6) Teardown runs after the server's tokens are deleted, so a preserved flow+mapping pair (the round-3 early-throw path) let a lingering consent tab complete the callback and recreate credentials post-uninstall. Now that both callback branches gate on stored-state equality, an orphaned mapping is the benign failure mode, so invert the order: delete the flow first, attempt the mapping delete regardless, and reject when either reports a storage failure. This supersedes the round-4 mapping restore, which also preserved a callback-capable pair. * fix: delete the flow even when its metadata read fails (Codex round 7) A storage error on the initial getFlowState aborted teardown before any delete ran, preserving the callback-capable flow after token deletion. Tolerate the read failure, delete the flow blindly, skip the mapping it could not identify (the callback gates neutralize the possible orphan), and reject so the caller's warn branch fires.
491 lines
17 KiB
JavaScript
491 lines
17 KiB
JavaScript
const mockUpdateUserPlugins = jest.fn();
|
|
const mockFindToken = jest.fn();
|
|
const mockDeleteUserPluginAuth = jest.fn();
|
|
const mockGetAppConfig = jest.fn();
|
|
const mockInvalidateCachedTools = jest.fn();
|
|
const mockGetLogStores = jest.fn();
|
|
const mockGetMCPManager = jest.fn();
|
|
const mockGetFlowStateManager = jest.fn();
|
|
const mockGetMCPServersRegistry = jest.fn();
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
getTenantId: jest.fn(),
|
|
webSearchKeys: [],
|
|
}));
|
|
|
|
jest.mock('librechat-data-provider', () => ({
|
|
Tools: {},
|
|
CacheKeys: { FLOWS: 'flows' },
|
|
Constants: { mcp_delimiter: '_mcp_', mcp_prefix: 'mcp_' },
|
|
FileSources: {},
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
MCPOAuthHandler: {
|
|
generateFlowId: jest.fn((userId, serverName, tenantId) => {
|
|
const flowId = `${userId}:${serverName}`;
|
|
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
|
}),
|
|
generateTokenFlowId: jest.fn((userId, serverName, tenantId) => {
|
|
const flowId = `${userId}:${serverName}`;
|
|
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
|
}),
|
|
deleteFlowAndStateMapping: jest.fn().mockResolvedValue(undefined),
|
|
revokeOAuthToken: jest.fn(),
|
|
},
|
|
MCPTokenStorage: {
|
|
getClientInfoAndMetadata: jest.fn(),
|
|
getTokens: jest.fn(),
|
|
deleteUserTokens: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
normalizeHttpError: jest.fn((error) => error),
|
|
extractWebSearchEnvVars: jest.fn((params) => params.keys),
|
|
getAppConfigOptionsFromUser: jest.fn((user) => {
|
|
const hasSourceIdentity =
|
|
user != null && Object.prototype.hasOwnProperty.call(user, 'idOnTheSource');
|
|
return {
|
|
role: user?.role,
|
|
userId: user?.id,
|
|
idOnTheSource: user?.id && hasSourceIdentity ? (user.idOnTheSource ?? null) : undefined,
|
|
tenantId: user?.tenantId,
|
|
};
|
|
}),
|
|
needsRefresh: jest.fn(),
|
|
getNewS3URL: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
|
|
findToken: mockFindToken,
|
|
deleteTokens: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/PluginService', () => ({
|
|
updateUserPluginAuth: jest.fn(),
|
|
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/twoFactorService', () => ({
|
|
verifyOTPOrBackupCode: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/AuthService', () => ({
|
|
verifyEmail: jest.fn(),
|
|
resendVerificationEmail: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: (...args) => mockGetMCPManager(...args),
|
|
getFlowStateManager: (...args) => mockGetFlowStateManager(...args),
|
|
getMCPServersRegistry: (...args) => mockGetMCPServersRegistry(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
|
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getAppConfig: (...args) => mockGetAppConfig(...args),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: (...args) => mockGetLogStores(...args),
|
|
}));
|
|
|
|
const { logger, getTenantId } = require('@librechat/data-schemas');
|
|
const { MCPTokenStorage, MCPOAuthHandler } = require('@librechat/api');
|
|
const { updateUserPluginsController } = require('~/server/controllers/UserController');
|
|
|
|
function createResponse() {
|
|
const res = {};
|
|
res.status = jest.fn().mockReturnValue(res);
|
|
res.json = jest.fn().mockReturnValue(res);
|
|
res.send = jest.fn().mockReturnValue(res);
|
|
return res;
|
|
}
|
|
|
|
function createRequest() {
|
|
return {
|
|
user: {
|
|
id: 'user-1',
|
|
_id: 'user-1',
|
|
plugins: [],
|
|
role: 'USER',
|
|
},
|
|
body: {
|
|
pluginKey: 'mcp_test-server',
|
|
action: 'uninstall',
|
|
auth: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
function setupMCPMocks() {
|
|
const flowManager = {
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
const mcpManager = {
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
|
};
|
|
const registry = {
|
|
getServerConfig: jest.fn().mockResolvedValue({
|
|
url: 'https://example.com/mcp',
|
|
oauth: {},
|
|
oauth_headers: {},
|
|
}),
|
|
getOAuthServers: jest.fn().mockResolvedValue(new Set(['test-server'])),
|
|
getAllowedDomains: jest.fn().mockReturnValue([]),
|
|
getAllowedAddresses: jest.fn().mockReturnValue(null),
|
|
};
|
|
|
|
// Revocation reads the merged config's mcpSettings allowlists (not the registry getters).
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpSettings: { allowedDomains: [], allowedAddresses: null },
|
|
});
|
|
mockUpdateUserPlugins.mockResolvedValue();
|
|
mockDeleteUserPluginAuth.mockResolvedValue();
|
|
mockInvalidateCachedTools.mockResolvedValue();
|
|
mockGetLogStores.mockReturnValue({});
|
|
mockGetFlowStateManager.mockReturnValue(flowManager);
|
|
mockGetMCPManager.mockReturnValue(mcpManager);
|
|
mockGetMCPServersRegistry.mockReturnValue(registry);
|
|
|
|
return { flowManager, mcpManager, registry };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
getTenantId.mockReturnValue(undefined);
|
|
});
|
|
|
|
describe('updateUserPluginsController MCP OAuth cleanup', () => {
|
|
it('clears stored OAuth token state when client metadata is missing', async () => {
|
|
const { flowManager, mcpManager } = setupMCPMocks();
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(MCPTokenStorage.getClientInfoAndMetadata).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
findToken: mockFindToken,
|
|
});
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
|
expect(mcpManager.disconnectUserConnection).toHaveBeenCalledWith('user-1', 'test-server');
|
|
});
|
|
|
|
it('still clears OAuth flow state when stored token deletion fails', async () => {
|
|
const { flowManager } = setupMCPMocks();
|
|
const cleanupError = new Error('DB down');
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
|
MCPTokenStorage.deleteUserTokens.mockRejectedValueOnce(cleanupError);
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for test-server:',
|
|
cleanupError,
|
|
);
|
|
});
|
|
|
|
it('logs all flow cleanup failures without failing MCP OAuth cleanup', async () => {
|
|
const { flowManager } = setupMCPMocks();
|
|
const getTokensFlowError = new Error('get tokens flow cache down');
|
|
const oauthFlowError = new Error('oauth flow cache down');
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
|
flowManager.deleteFlow.mockRejectedValueOnce(getTokensFlowError);
|
|
MCPOAuthHandler.deleteFlowAndStateMapping.mockRejectedValueOnce(oauthFlowError);
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
|
|
getTokensFlowError,
|
|
);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
|
|
oauthFlowError,
|
|
);
|
|
});
|
|
|
|
it('clears stored OAuth token state when client metadata cannot be loaded', async () => {
|
|
const { flowManager } = setupMCPMocks();
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockRejectedValue(new Error('invalid client info'));
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for test-server; clearing local MCP OAuth state only.',
|
|
expect.any(Error),
|
|
);
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(MCPTokenStorage.getTokens).not.toHaveBeenCalled();
|
|
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears tenant-scoped and legacy OAuth flow state when tenant context exists', async () => {
|
|
const { flowManager } = setupMCPMocks();
|
|
getTenantId.mockReturnValue('tenant-a');
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith(
|
|
'tenant:tenant-a:user-1:test-server',
|
|
'mcp_get_tokens',
|
|
);
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'tenant:tenant-a:user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
});
|
|
|
|
it('clears stored OAuth token state when server config is missing', async () => {
|
|
const { flowManager, registry } = setupMCPMocks();
|
|
registry.getServerConfig.mockResolvedValue(undefined);
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
|
|
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears stored OAuth token state when server no longer requires OAuth', async () => {
|
|
const { flowManager, registry } = setupMCPMocks();
|
|
registry.getOAuthServers.mockResolvedValue(new Set());
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
|
|
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears stored OAuth token state when token loading fails before provider revocation', async () => {
|
|
const { flowManager } = setupMCPMocks();
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
|
clientInfo: { client_id: 'client-1' },
|
|
clientMetadata: {},
|
|
});
|
|
MCPTokenStorage.getTokens.mockRejectedValue(new Error('token lookup failed'));
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
findToken: mockFindToken,
|
|
});
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[maybeUninstallOAuthMCP] Unable to load OAuth tokens for test-server; clearing local token state.',
|
|
expect.any(Error),
|
|
);
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
|
expect(MCPOAuthHandler.deleteFlowAndStateMapping).toHaveBeenCalledWith(
|
|
'user-1:test-server',
|
|
flowManager,
|
|
);
|
|
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('revokes provider tokens before clearing local token state when token data is available', async () => {
|
|
setupMCPMocks();
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
|
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
|
|
clientMetadata: { revocation_endpoint: 'https://example.com/revoke' },
|
|
});
|
|
MCPTokenStorage.getTokens.mockResolvedValue({
|
|
access_token: 'access-token',
|
|
refresh_token: 'refresh-token',
|
|
});
|
|
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
findToken: mockFindToken,
|
|
});
|
|
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'access-token',
|
|
'access',
|
|
{
|
|
serverUrl: 'https://example.com/mcp',
|
|
clientId: 'client-1',
|
|
clientSecret: 'secret-1',
|
|
revocationEndpoint: 'https://example.com/revoke',
|
|
revocationEndpointAuthMethodsSupported: undefined,
|
|
},
|
|
{},
|
|
[],
|
|
null,
|
|
);
|
|
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'refresh-token',
|
|
'refresh',
|
|
{
|
|
serverUrl: 'https://example.com/mcp',
|
|
clientId: 'client-1',
|
|
clientSecret: 'secret-1',
|
|
revocationEndpoint: 'https://example.com/revoke',
|
|
revocationEndpointAuthMethodsSupported: undefined,
|
|
},
|
|
{},
|
|
[],
|
|
null,
|
|
);
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('revokes only the access token when refresh token data is absent', async () => {
|
|
setupMCPMocks();
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
|
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
|
|
clientMetadata: {},
|
|
});
|
|
MCPTokenStorage.getTokens.mockResolvedValue({
|
|
access_token: 'access-token',
|
|
});
|
|
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
|
|
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'access-token',
|
|
'access',
|
|
expect.objectContaining({ clientId: 'client-1' }),
|
|
{},
|
|
[],
|
|
null,
|
|
);
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('revokes only the refresh token when access token data is absent', async () => {
|
|
setupMCPMocks();
|
|
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
|
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
|
|
clientMetadata: {},
|
|
});
|
|
MCPTokenStorage.getTokens.mockResolvedValue({
|
|
refresh_token: 'refresh-token',
|
|
});
|
|
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
|
|
|
|
const res = createResponse();
|
|
await updateUserPluginsController(createRequest(), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
|
|
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'refresh-token',
|
|
'refresh',
|
|
expect.objectContaining({ clientId: 'client-1' }),
|
|
{},
|
|
[],
|
|
null,
|
|
);
|
|
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
|
userId: 'user-1',
|
|
serverName: 'test-server',
|
|
deleteToken: expect.any(Function),
|
|
});
|
|
});
|
|
});
|