e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
const mockGetTokens = jest.fn();
|
|
const mockDeleteUserTokens = jest.fn();
|
|
const mockGetClientInfoAndMetadata = jest.fn();
|
|
const mockRevokeOAuthToken = jest.fn();
|
|
const mockGetServerConfig = jest.fn();
|
|
const mockGetOAuthServers = jest.fn();
|
|
const mockGetAllowedDomains = jest.fn();
|
|
const mockGetAllowedAddresses = jest.fn();
|
|
const mockDeleteFlow = jest.fn();
|
|
const mockGetLogStores = jest.fn();
|
|
const mockFindToken = jest.fn();
|
|
const mockDeleteTokens = jest.fn();
|
|
const mockLoggerInfo = jest.fn();
|
|
const mockLoggerWarn = jest.fn();
|
|
const mockLoggerError = jest.fn();
|
|
const mockGetTenantId = jest.fn();
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError },
|
|
getTenantId: (...args) => mockGetTenantId(...args),
|
|
webSearchKeys: [],
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => {
|
|
return {
|
|
MCPOAuthHandler: {
|
|
revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args),
|
|
generateFlowId: (userId, serverName, tenantId) => {
|
|
const flowId = `${userId}:${serverName}`;
|
|
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
|
},
|
|
generateTokenFlowId: (userId, serverName, tenantId) => {
|
|
const flowId = `${userId}:${serverName}`;
|
|
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
|
},
|
|
},
|
|
MCPTokenStorage: {
|
|
getTokens: (...args) => mockGetTokens(...args),
|
|
getClientInfoAndMetadata: (...args) => mockGetClientInfoAndMetadata(...args),
|
|
deleteUserTokens: (...args) => mockDeleteUserTokens(...args),
|
|
},
|
|
normalizeHttpError: jest.fn(),
|
|
extractWebSearchEnvVars: jest.fn(),
|
|
needsRefresh: jest.fn(),
|
|
getNewS3URL: jest.fn(),
|
|
};
|
|
});
|
|
|
|
jest.mock('librechat-data-provider', () => ({
|
|
Tools: {},
|
|
CacheKeys: { FLOWS: 'flows' },
|
|
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
|
|
FileSources: {},
|
|
ResourceType: {},
|
|
}));
|
|
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: jest.fn(),
|
|
getFlowStateManager: jest.fn(() => ({
|
|
deleteFlow: (...args) => mockDeleteFlow(...args),
|
|
})),
|
|
getMCPServersRegistry: jest.fn(() => ({
|
|
getServerConfig: (...args) => mockGetServerConfig(...args),
|
|
getOAuthServers: (...args) => mockGetOAuthServers(...args),
|
|
getAllowedDomains: (...args) => mockGetAllowedDomains(...args),
|
|
getAllowedAddresses: (...args) => mockGetAllowedAddresses(...args),
|
|
})),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: (...args) => mockGetLogStores(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/PluginService', () => ({
|
|
updateUserPluginAuth: jest.fn(),
|
|
deleteUserPluginAuth: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/twoFactorService', () => ({
|
|
verifyOTPOrBackupCode: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/AuthService', () => ({
|
|
verifyEmail: jest.fn(),
|
|
resendVerificationEmail: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
|
invalidateCachedTools: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getAppConfig: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
findToken: (...args) => mockFindToken(...args),
|
|
deleteTokens: (...args) => mockDeleteTokens(...args),
|
|
updateUser: jest.fn(),
|
|
deleteAllUserSessions: jest.fn(),
|
|
deleteAllSharedLinks: jest.fn(),
|
|
updateUserPlugins: jest.fn(),
|
|
deleteUserById: jest.fn(),
|
|
deleteMessages: jest.fn(),
|
|
deletePresets: jest.fn(),
|
|
deleteUserKey: jest.fn(),
|
|
getUserById: jest.fn(),
|
|
deleteConvos: jest.fn(),
|
|
deleteFiles: jest.fn(),
|
|
getFiles: jest.fn(),
|
|
deleteToolCalls: jest.fn(),
|
|
deleteUserAgents: jest.fn(),
|
|
deleteUserPrompts: jest.fn(),
|
|
deleteTransactions: jest.fn(),
|
|
deleteBalances: jest.fn(),
|
|
deleteAllAgentApiKeys: jest.fn(),
|
|
deleteAssistants: jest.fn(),
|
|
deleteConversationTags: jest.fn(),
|
|
deleteAllUserMemories: jest.fn(),
|
|
deleteActions: jest.fn(),
|
|
removeUserFromAllGroups: jest.fn(),
|
|
deleteAclEntries: jest.fn(),
|
|
getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
const { maybeUninstallOAuthMCP } = require('~/server/controllers/UserController');
|
|
|
|
const userId = 'user-123';
|
|
const pluginKey = 'mcp_acme';
|
|
const serverName = 'acme';
|
|
|
|
const serverConfig = {
|
|
url: 'https://acme.example.com',
|
|
oauth: {
|
|
revocation_endpoint: 'https://acme.example.com/revoke',
|
|
revocation_endpoint_auth_methods_supported: ['client_secret_basic'],
|
|
},
|
|
oauth_headers: { 'X-Tenant': 'acme' },
|
|
};
|
|
|
|
const appConfig = {
|
|
mcpServers: { acme: serverConfig },
|
|
};
|
|
|
|
const clientInfo = { client_id: 'cid', client_secret: 'csec' };
|
|
const clientMetadata = {};
|
|
|
|
function setupOAuthServerFound() {
|
|
mockGetServerConfig.mockResolvedValue(serverConfig);
|
|
mockGetOAuthServers.mockResolvedValue(new Set([serverName]));
|
|
mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']);
|
|
mockGetAllowedAddresses.mockReturnValue(null);
|
|
mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata });
|
|
}
|
|
|
|
describe('maybeUninstallOAuthMCP', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockGetTenantId.mockReturnValue(undefined);
|
|
});
|
|
|
|
test('is a no-op when pluginKey is not an MCP key', async () => {
|
|
await maybeUninstallOAuthMCP(userId, 'plugin_google_calendar', appConfig);
|
|
|
|
expect(mockGetServerConfig).not.toHaveBeenCalled();
|
|
expect(mockGetTokens).not.toHaveBeenCalled();
|
|
expect(mockDeleteUserTokens).not.toHaveBeenCalled();
|
|
expect(mockDeleteFlow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('clears stored state when the MCP server is not an OAuth server', async () => {
|
|
mockGetServerConfig.mockResolvedValue(serverConfig);
|
|
mockGetOAuthServers.mockResolvedValue(new Set(['other']));
|
|
|
|
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
|
|
|
expect(mockGetClientInfoAndMetadata).not.toHaveBeenCalled();
|
|
expect(mockGetTokens).not.toHaveBeenCalled();
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('clears stored state when client info is missing', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetClientInfoAndMetadata.mockResolvedValue(null);
|
|
|
|
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
|
|
|
expect(mockGetTokens).not.toHaveBeenCalled();
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('clears stored state when client info cannot be loaded', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetClientInfoAndMetadata.mockRejectedValue(new Error('bad client data'));
|
|
mockDeleteUserTokens.mockResolvedValue(undefined);
|
|
mockDeleteFlow.mockResolvedValue(undefined);
|
|
|
|
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
|
|
|
expect(mockGetTokens).not.toHaveBeenCalled();
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
|
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
test('clears tenant-scoped and legacy flow state when tenant context exists', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetTenantId.mockReturnValue('tenant-a');
|
|
mockGetClientInfoAndMetadata.mockResolvedValue(null);
|
|
|
|
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
|
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(4);
|
|
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_get_tokens');
|
|
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_oauth');
|
|
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_get_tokens');
|
|
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_oauth');
|
|
});
|
|
|
|
test('revokes both tokens and runs cleanup on happy path', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetTokens.mockResolvedValue({
|
|
access_token: 'access-abc',
|
|
refresh_token: 'refresh-xyz',
|
|
});
|
|
mockRevokeOAuthToken.mockResolvedValue(undefined);
|
|
mockDeleteUserTokens.mockResolvedValue(undefined);
|
|
mockDeleteFlow.mockResolvedValue(undefined);
|
|
|
|
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
|
|
|
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
|
|
expect(mockRevokeOAuthToken.mock.calls[0][1]).toBe('access-abc');
|
|
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
|
|
expect(mockRevokeOAuthToken.mock.calls[1][1]).toBe('refresh-xyz');
|
|
expect(mockRevokeOAuthToken.mock.calls[1][2]).toBe('refresh');
|
|
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
|
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
expect(mockDeleteFlow.mock.calls[0][1]).toBe('mcp_get_tokens');
|
|
expect(mockDeleteFlow.mock.calls[1][1]).toBe('mcp_oauth');
|
|
});
|
|
|
|
test('skips revocation but still runs cleanup when token retrieval fails', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetTokens.mockRejectedValue(new Error('missing'));
|
|
mockDeleteUserTokens.mockResolvedValue(undefined);
|
|
mockDeleteFlow.mockResolvedValue(undefined);
|
|
|
|
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
|
|
|
|
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
|
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
test('skips revocation, logs warn, and still runs cleanup on unexpected token-retrieval error', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetTokens.mockRejectedValue(new Error('boom: unreachable'));
|
|
mockDeleteUserTokens.mockResolvedValue(undefined);
|
|
mockDeleteFlow.mockResolvedValue(undefined);
|
|
|
|
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
|
|
|
|
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
|
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
test('continues cleanup when only one token type is present', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetTokens.mockResolvedValue({ access_token: 'only-access' });
|
|
mockRevokeOAuthToken.mockResolvedValue(undefined);
|
|
mockDeleteUserTokens.mockResolvedValue(undefined);
|
|
mockDeleteFlow.mockResolvedValue(undefined);
|
|
|
|
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
|
|
|
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(1);
|
|
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('still runs cleanup even when both revocation calls fail', async () => {
|
|
setupOAuthServerFound();
|
|
mockGetTokens.mockResolvedValue({
|
|
access_token: 'a',
|
|
refresh_token: 'r',
|
|
});
|
|
mockRevokeOAuthToken.mockRejectedValue(new Error('network down'));
|
|
mockDeleteUserTokens.mockResolvedValue(undefined);
|
|
mockDeleteFlow.mockResolvedValue(undefined);
|
|
|
|
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
|
|
|
|
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
|
|
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
|
expect(mockLoggerError).toHaveBeenCalled();
|
|
});
|
|
});
|