diff --git a/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx
new file mode 100644
index 0000000000..3cb4bb9603
--- /dev/null
+++ b/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx
@@ -0,0 +1,2264 @@
+import type { ReactNode } from 'react'
+import type { PluginPayload } from '../types'
+import type { FormSchema } from '@/app/components/base/form/types'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { AuthCategory } from '../types'
+
+// Create a wrapper with QueryClientProvider
+const createTestQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ })
+
+const createWrapper = () => {
+ const testQueryClient = createTestQueryClient()
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+// Mock API hooks - these make network requests so must be mocked
+const mockGetPluginOAuthUrl = vi.fn()
+const mockGetPluginOAuthClientSchema = vi.fn()
+const mockSetPluginOAuthCustomClient = vi.fn()
+const mockDeletePluginOAuthCustomClient = vi.fn()
+const mockInvalidPluginOAuthClientSchema = vi.fn()
+const mockAddPluginCredential = vi.fn()
+const mockUpdatePluginCredential = vi.fn()
+const mockGetPluginCredentialSchema = vi.fn()
+
+vi.mock('../hooks/use-credential', () => ({
+ useGetPluginOAuthUrlHook: () => ({
+ mutateAsync: mockGetPluginOAuthUrl,
+ }),
+ useGetPluginOAuthClientSchemaHook: () => ({
+ data: mockGetPluginOAuthClientSchema(),
+ isLoading: false,
+ }),
+ useSetPluginOAuthCustomClientHook: () => ({
+ mutateAsync: mockSetPluginOAuthCustomClient,
+ }),
+ useDeletePluginOAuthCustomClientHook: () => ({
+ mutateAsync: mockDeletePluginOAuthCustomClient,
+ }),
+ useInvalidPluginOAuthClientSchemaHook: () => mockInvalidPluginOAuthClientSchema,
+ useAddPluginCredentialHook: () => ({
+ mutateAsync: mockAddPluginCredential,
+ }),
+ useUpdatePluginCredentialHook: () => ({
+ mutateAsync: mockUpdatePluginCredential,
+ }),
+ useGetPluginCredentialSchemaHook: () => ({
+ data: mockGetPluginCredentialSchema(),
+ isLoading: false,
+ }),
+}))
+
+// Mock openOAuthPopup - requires window operations
+const mockOpenOAuthPopup = vi.fn()
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: (...args: unknown[]) => mockOpenOAuthPopup(...args),
+}))
+
+// Mock service/use-triggers - API service
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerPluginDynamicOptions: () => ({
+ data: { options: [] },
+ isLoading: false,
+ }),
+ useTriggerPluginDynamicOptionsInfo: () => ({
+ data: null,
+ isLoading: false,
+ }),
+ useInvalidTriggerDynamicOptions: () => vi.fn(),
+}))
+
+// Mock AuthForm to control form validation in tests
+const mockGetFormValues = vi.fn()
+vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
+ default: vi.fn().mockImplementation(({ ref }: { ref: { current: unknown } }) => {
+ if (ref)
+ ref.current = { getFormValues: mockGetFormValues }
+
+ return
Auth Form
+ }),
+}))
+
+// Mock useToastContext
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({ notify: mockNotify }),
+}))
+
+// Factory function for creating test PluginPayload
+const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ ...overrides,
+})
+
+// Factory for form schemas
+const createFormSchema = (overrides: Partial = {}): FormSchema => ({
+ type: 'text-input' as FormSchema['type'],
+ name: 'test-field',
+ label: 'Test Field',
+ required: false,
+ ...overrides,
+})
+
+// ==================== AddApiKeyButton Tests ====================
+describe('AddApiKeyButton', () => {
+ let AddApiKeyButton: typeof import('./add-api-key-button').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialSchema.mockReturnValue([])
+ const importedAddApiKeyButton = await import('./add-api-key-button')
+ AddApiKeyButton = importedAddApiKeyButton.default
+ })
+
+ describe('Rendering', () => {
+ it('should render button with default text', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByRole('button')).toHaveTextContent('Use Api Key')
+ })
+
+ it('should render button with custom text', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toHaveTextContent('Custom API Key')
+ })
+
+ it('should apply button variant', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button').className).toContain('btn-primary')
+ })
+
+ it('should use secondary-accent variant by default', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByRole('button').className).toContain('btn-secondary-accent')
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should disable button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should not disable button when disabled prop is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).not.toBeDisabled()
+ })
+
+ it('should accept formSchemas prop', () => {
+ const pluginPayload = createPluginPayload()
+ const formSchemas = [createFormSchema({ name: 'api_key', label: 'API Key' })]
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should open modal when button is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+
+ it('should not open modal when button is disabled', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Modal should not appear
+ expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty pluginPayload properties', () => {
+ const pluginPayload = createPluginPayload({
+ provider: '',
+ providerType: undefined,
+ })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle all auth categories', () => {
+ const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger]
+
+ categories.forEach((category) => {
+ const pluginPayload = createPluginPayload({ category })
+ const { unmount } = render(, { wrapper: createWrapper() })
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ unmount()
+ })
+ })
+ })
+
+ describe('Modal Behavior', () => {
+ it('should close modal when onClose is called from ApiKeyModal', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(, { wrapper: createWrapper() })
+
+ // Open modal
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ // Close modal via cancel button
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should call onUpdate when provided and modal triggers update', async () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Open modal
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be a memoized component', async () => {
+ const AddApiKeyButtonDefault = (await import('./add-api-key-button')).default
+ expect(typeof AddApiKeyButtonDefault).toBe('object')
+ })
+ })
+})
+
+// ==================== AddOAuthButton Tests ====================
+describe('AddOAuthButton', () => {
+ let AddOAuthButton: typeof import('./add-oauth-button').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ client_params: {},
+ redirect_uri: 'https://example.com/callback',
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' })
+ const importedAddOAuthButton = await import('./add-oauth-button')
+ AddOAuthButton = importedAddOAuthButton.default
+ })
+
+ describe('Rendering - Not Configured State', () => {
+ it('should render setup OAuth button when not configured', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument()
+ })
+
+ it('should apply button variant to setup button', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button').className).toContain('btn-secondary')
+ })
+ })
+
+ describe('Rendering - Configured State', () => {
+ it('should render OAuth button when system OAuth params exist', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('Connect OAuth')).toBeInTheDocument()
+ })
+
+ it('should render OAuth button when custom client is enabled', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('OAuth')).toBeInTheDocument()
+ })
+
+ it('should show custom badge when custom client is enabled', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should disable button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should apply custom className', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button').className).toContain('custom-class')
+ })
+
+ it('should use oAuthData prop when provided', () => {
+ const pluginPayload = createPluginPayload()
+ const oAuthData = {
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: true,
+ client_params: {},
+ redirect_uri: 'https://custom.example.com/callback',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Should render configured button since oAuthData has is_system_oauth_params_exists=true
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should trigger OAuth flow when configured button is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click the main button area (left side)
+ const buttonText = screen.getByText('use oauth')
+ fireEvent.click(buttonText)
+
+ await waitFor(() => {
+ expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
+ })
+ })
+
+ it('should open settings when setup button is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should not trigger OAuth when no authorization_url is returned', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: '' })
+
+ render(, { wrapper: createWrapper() })
+
+ const buttonText = screen.getByText('use oauth')
+ fireEvent.click(buttonText)
+
+ await waitFor(() => {
+ expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
+ })
+
+ expect(mockOpenOAuthPopup).not.toHaveBeenCalled()
+ })
+
+ it('should call onUpdate callback after successful OAuth', async () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ })
+ mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' })
+ // Simulate openOAuthPopup calling the success callback
+ mockOpenOAuthPopup.mockImplementation((url, callback) => {
+ callback?.()
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttonText = screen.getByText('use oauth')
+ fireEvent.click(buttonText)
+
+ await waitFor(() => {
+ expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
+ 'https://oauth.example.com/auth',
+ expect.any(Function),
+ )
+ })
+
+ // Verify onUpdate was called through the callback
+ expect(onUpdate).toHaveBeenCalled()
+ })
+
+ it('should open OAuth settings when settings icon is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Find and click the settings icon (right side of split button)
+ const settingsIcon = screen.getByRole('button').querySelector('[class*="shrink-0"][class*="w-8"]')
+ if (settingsIcon) {
+ fireEvent.click(settingsIcon)
+ }
+ else {
+ // Alternative: click by finding the RiEqualizer2Line icon's parent
+ const icons = screen.getByRole('button').querySelectorAll('svg')
+ const settingsButton = icons[icons.length - 1]?.parentElement
+ if (settingsButton)
+ fireEvent.click(settingsButton)
+ }
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should close OAuth settings modal when onClose is called', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Open settings
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ // Close settings via cancel button
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByText('plugin.auth.oauthClientSettings')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Schema Processing', () => {
+ it('should handle is_system_oauth_params_exists state', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Should show the configured button, not setup button
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+
+ it('should open OAuth settings modal with correct data', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ // OAuthClientSettings modal should open
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle client_params defaults in schema', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [
+ createFormSchema({ name: 'client_id', label: 'Client ID' }),
+ createFormSchema({ name: 'client_secret', label: 'Client Secret' }),
+ ],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ client_params: {
+ client_id: 'preset-client-id',
+ client_secret: 'preset-secret',
+ },
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Open settings by clicking the gear icon
+ const button = screen.getByRole('button')
+ const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]')
+ if (gearIconContainer)
+ fireEvent.click(gearIconContainer)
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle __auth_client__ logic when configured with system OAuth and no custom client', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ client_params: {},
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Should render configured button (not setup button)
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+
+ it('should open OAuth settings when system OAuth params exist', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // Click the settings icon
+ const button = screen.getByRole('button')
+ const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]')
+ if (gearIconContainer)
+ fireEvent.click(gearIconContainer)
+
+ await waitFor(() => {
+ // OAuthClientSettings modal should open
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Clipboard Operations', () => {
+ it('should have clipboard API available for copy operations', async () => {
+ const pluginPayload = createPluginPayload()
+ const mockWriteText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: mockWriteText },
+ configurable: true,
+ })
+
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ // OAuthClientSettings modal opens
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ // Verify clipboard API is available
+ expect(navigator.clipboard.writeText).toBeDefined()
+ })
+ })
+
+ describe('__auth_client__ Logic', () => {
+ it('should return default when not configured and system OAuth params exist', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: true,
+ client_params: {},
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // When isConfigured is true (is_system_oauth_params_exists=true), it should show the configured button
+ expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
+ })
+
+ it('should return custom when not configured and no system OAuth params', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ client_params: {},
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ // When not configured, it should show the setup button
+ expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty schema', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle undefined oAuthData fields', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue(undefined)
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle null client_params', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'test' })],
+ is_oauth_custom_client_enabled: true,
+ is_system_oauth_params_exists: true,
+ client_params: null,
+ })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+ })
+})
+
+// ==================== ApiKeyModal Tests ====================
+describe('ApiKeyModal', () => {
+ let ApiKeyModal: typeof import('./api-key-modal').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key', required: true }),
+ ])
+ mockAddPluginCredential.mockResolvedValue({})
+ mockUpdatePluginCredential.mockResolvedValue({})
+ // Reset form values mock to return validation failed by default
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: false,
+ values: {},
+ })
+ const importedApiKeyModal = await import('./api-key-modal')
+ ApiKeyModal = importedApiKeyModal.default
+ })
+
+ describe('Rendering', () => {
+ it('should render modal with title', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ it('should render modal with subtitle', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ expect(screen.getByText('plugin.auth.useApiAuthDesc')).toBeInTheDocument()
+ })
+
+ it('should render form when data is loaded', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ // AuthForm is mocked, so check for the mock element
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should call onClose when modal is closed', () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Find and click cancel button
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should disable confirm button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const confirmButton = screen.getByText('common.operation.save')
+ expect(confirmButton.closest('button')).toBeDisabled()
+ })
+
+ it('should show modal when editValues is provided', () => {
+ const pluginPayload = createPluginPayload()
+ const editValues = {
+ __name__: 'Test Name',
+ __credential_id__: 'test-id',
+ api_key: 'test-key',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ it('should use formSchemas from props when provided', () => {
+ const pluginPayload = createPluginPayload()
+ const customSchemas = [
+ createFormSchema({ name: 'custom_field', label: 'Custom Field' }),
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // AuthForm is mocked, verify modal renders
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Behavior', () => {
+ it('should render AuthForm component', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ // AuthForm is mocked, verify it's rendered
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+
+ it('should render modal with editValues', () => {
+ const pluginPayload = createPluginPayload()
+ const editValues = {
+ __name__: 'Existing Name',
+ api_key: 'existing-key',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Submission - handleConfirm', () => {
+ beforeEach(() => {
+ // Default: form validation passes with empty values
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __name__: 'Test Name',
+ api_key: 'test-api-key',
+ },
+ })
+ })
+
+ it('should call addPluginCredential when creating new credential', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockAddPluginCredential.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockAddPluginCredential).toHaveBeenCalled()
+ })
+ })
+
+ it('should call updatePluginCredential when editing existing credential', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ const editValues = {
+ __name__: 'Test Credential',
+ __credential_id__: 'test-credential-id',
+ api_key: 'existing-key',
+ }
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockUpdatePluginCredential.mockResolvedValue({})
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __name__: 'Test Credential',
+ __credential_id__: 'test-credential-id',
+ api_key: 'updated-key',
+ },
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePluginCredential).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onClose and onUpdate after successful submission', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockAddPluginCredential.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled()
+ expect(onUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call API when form validation fails', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key', required: true }),
+ ])
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: false,
+ values: {},
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+
+ // Wait a bit and verify API was not called
+ await new Promise(resolve => setTimeout(resolve, 100))
+ expect(mockAddPluginCredential).not.toHaveBeenCalled()
+ })
+
+ it('should handle doingAction state to prevent double submission', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ // Make the API call slow
+ mockAddPluginCredential.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click confirm button twice quickly
+ const confirmButton = screen.getByText('common.operation.save')
+ fireEvent.click(confirmButton)
+ fireEvent.click(confirmButton)
+
+ // Should only be called once due to doingAction guard
+ await waitFor(() => {
+ expect(mockAddPluginCredential).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should return early if doingActionRef is true during concurrent clicks', async () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ // Create a promise that we can control
+ let resolveFirstCall: (value?: unknown) => void
+ let apiCallCount = 0
+
+ mockAddPluginCredential.mockImplementation(() => {
+ apiCallCount++
+ if (apiCallCount === 1) {
+ // First call: return a pending promise
+ return new Promise((resolve) => {
+ resolveFirstCall = resolve
+ })
+ }
+ // Subsequent calls should not happen but return resolved promise
+ return Promise.resolve({})
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const confirmButton = screen.getByText('common.operation.save')
+
+ // First click starts the request
+ fireEvent.click(confirmButton)
+
+ // Wait a tick to ensure state updates
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ // Second click while first request is still pending
+ fireEvent.click(confirmButton)
+
+ // Wait a bit more
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ // Only one API call should have been made
+ expect(apiCallCount).toBe(1)
+
+ // Clean up by resolving the promise
+ resolveFirstCall!()
+ })
+
+ it('should call onRemove when extra button is clicked in edit mode', async () => {
+ const pluginPayload = createPluginPayload()
+ const onRemove = vi.fn()
+ const editValues = {
+ __name__: 'Test Credential',
+ __credential_id__: 'test-credential-id',
+ }
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Find and click the remove button
+ const removeButton = screen.getByText('common.operation.remove')
+ fireEvent.click(removeButton)
+
+ expect(onRemove).toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty credentials schema', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([])
+
+ render(, { wrapper: createWrapper() })
+
+ // Should still render the modal with authorization name field
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+
+ it('should handle undefined detail in pluginPayload', () => {
+ const pluginPayload = createPluginPayload({ detail: undefined })
+
+ expect(() => {
+ render(, { wrapper: createWrapper() })
+ }).not.toThrow()
+ })
+
+ it('should handle form schema with default values', () => {
+ const pluginPayload = createPluginPayload()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key', default: 'default-key' }),
+ ])
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+})
+
+// ==================== OAuthClientSettings Tests ====================
+describe('OAuthClientSettings', () => {
+ let OAuthClientSettings: typeof import('./oauth-client-settings').default
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+ mockDeletePluginOAuthCustomClient.mockResolvedValue({})
+ const importedOAuthClientSettings = await import('./oauth-client-settings')
+ OAuthClientSettings = importedOAuthClientSettings.default
+ })
+
+ const defaultSchemas: FormSchema[] = [
+ createFormSchema({ name: 'client_id', label: 'Client ID', required: true }),
+ createFormSchema({ name: 'client_secret', label: 'Client Secret', required: true }),
+ ]
+
+ describe('Rendering', () => {
+ it('should render modal with correct title', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ it('should render Save and Auth button', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument()
+ })
+
+ it('should render Save Only button', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument()
+ })
+
+ it('should render Cancel button', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
+ })
+
+ it('should render form from schemas', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // AuthForm is mocked
+ expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Testing', () => {
+ it('should call onClose when cancel button is clicked', () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should disable buttons when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const confirmButton = screen.getByText('plugin.auth.saveAndAuth')
+ expect(confirmButton.closest('button')).toBeDisabled()
+ })
+
+ it('should render with editValues', () => {
+ const pluginPayload = createPluginPayload()
+ const editValues = {
+ client_id: 'existing-client-id',
+ client_secret: 'existing-secret',
+ __oauth_client__: 'custom',
+ }
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ describe('Remove Button', () => {
+ it('should show remove button when custom client and hasOriginalClientParams', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
+ })
+
+ it('should not show remove button when using default client', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'default',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Form Submission', () => {
+ beforeEach(() => {
+ // Default: form validation passes
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __oauth_client__: 'custom',
+ client_id: 'test-client-id',
+ client_secret: 'test-secret',
+ },
+ })
+ })
+
+ it('should render Save and Auth button that is clickable', async () => {
+ const pluginPayload = createPluginPayload()
+ const onAuth = vi.fn().mockResolvedValue(undefined)
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const saveAndAuthButton = screen.getByText('plugin.auth.saveAndAuth')
+ expect(saveAndAuthButton).toBeInTheDocument()
+ expect(saveAndAuthButton.closest('button')).not.toBeDisabled()
+ })
+
+ it('should call setPluginOAuthCustomClient when Save Only is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Save Only button
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onClose and onUpdate after successful submission', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled()
+ expect(onUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onAuth after handleConfirmAndAuthorize', async () => {
+ const pluginPayload = createPluginPayload()
+ const onAuth = vi.fn().mockResolvedValue(undefined)
+ const onClose = vi.fn()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Save and Auth button
+ fireEvent.click(screen.getByText('plugin.auth.saveAndAuth'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled()
+ expect(onAuth).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle form with empty values', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Modal should render with save buttons
+ expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument()
+ expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument()
+ })
+
+ it('should call deletePluginOAuthCustomClient when Remove is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockDeletePluginOAuthCustomClient.mockResolvedValue({})
+
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Remove button
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ await waitFor(() => {
+ expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onClose and onUpdate after successful removal', async () => {
+ const pluginPayload = createPluginPayload()
+ const onClose = vi.fn()
+ const onUpdate = vi.fn()
+ mockDeletePluginOAuthCustomClient.mockResolvedValue({})
+
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled()
+ expect(onUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should prevent double submission when doingAction is true', async () => {
+ const pluginPayload = createPluginPayload()
+ // Make the API call slow
+ mockSetPluginOAuthCustomClient.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click Save Only button twice quickly
+ const saveButton = screen.getByText('plugin.auth.saveOnly')
+ fireEvent.click(saveButton)
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should return early from handleConfirm if doingActionRef is true', async () => {
+ const pluginPayload = createPluginPayload()
+ let resolveFirstCall: (value?: unknown) => void
+ let apiCallCount = 0
+
+ mockSetPluginOAuthCustomClient.mockImplementation(() => {
+ apiCallCount++
+ if (apiCallCount === 1) {
+ return new Promise((resolve) => {
+ resolveFirstCall = resolve
+ })
+ }
+ return Promise.resolve({})
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const saveButton = screen.getByText('plugin.auth.saveOnly')
+
+ // First click starts the request
+ fireEvent.click(saveButton)
+
+ // Wait a tick to ensure state updates
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ // Second click while first request is pending
+ fireEvent.click(saveButton)
+
+ // Wait a bit more
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ // Only one API call should have been made
+ expect(apiCallCount).toBe(1)
+
+ // Clean up
+ resolveFirstCall!()
+ })
+
+ it('should return early from handleRemove if doingActionRef is true', async () => {
+ const pluginPayload = createPluginPayload()
+ let resolveFirstCall: (value?: unknown) => void
+ let deleteCallCount = 0
+
+ mockDeletePluginOAuthCustomClient.mockImplementation(() => {
+ deleteCallCount++
+ if (deleteCallCount === 1) {
+ return new Promise((resolve) => {
+ resolveFirstCall = resolve
+ })
+ }
+ return Promise.resolve({})
+ })
+
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const removeButton = screen.getByText('common.operation.remove')
+
+ // First click starts the delete request
+ fireEvent.click(removeButton)
+
+ // Wait a tick to ensure state updates
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ // Second click while first request is pending
+ fireEvent.click(removeButton)
+
+ // Wait a bit more
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ // Only one delete call should have been made
+ expect(deleteCallCount).toBe(1)
+
+ // Clean up
+ resolveFirstCall!()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty schemas', () => {
+ const pluginPayload = createPluginPayload()
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle schemas without default values', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithoutDefaults: FormSchema[] = [
+ createFormSchema({ name: 'field1', label: 'Field 1', default: undefined }),
+ ]
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle undefined editValues', () => {
+ const pluginPayload = createPluginPayload()
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('Branch Coverage - defaultValues computation', () => {
+ it('should compute defaultValues from schemas with default values', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithDefaults: FormSchema[] = [
+ createFormSchema({ name: 'client_id', label: 'Client ID', default: 'default-id' }),
+ createFormSchema({ name: 'client_secret', label: 'Client Secret', default: 'default-secret' }),
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ it('should skip schemas without default values in defaultValues computation', () => {
+ const pluginPayload = createPluginPayload()
+ const mixedSchemas: FormSchema[] = [
+ createFormSchema({ name: 'field_with_default', label: 'With Default', default: 'value' }),
+ createFormSchema({ name: 'field_without_default', label: 'Without Default', default: undefined }),
+ createFormSchema({ name: 'field_with_empty', label: 'Empty Default', default: '' }),
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ describe('Branch Coverage - __oauth_client__ value', () => {
+ beforeEach(() => {
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __oauth_client__: 'default',
+ client_id: 'test-id',
+ },
+ })
+ })
+
+ it('should send enable_oauth_custom_client=false when __oauth_client__ is default', async () => {
+ const pluginPayload = createPluginPayload()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enable_oauth_custom_client: false,
+ }),
+ )
+ })
+ })
+
+ it('should send enable_oauth_custom_client=true when __oauth_client__ is custom', async () => {
+ const pluginPayload = createPluginPayload()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: {
+ __oauth_client__: 'custom',
+ client_id: 'test-id',
+ },
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveOnly'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enable_oauth_custom_client: true,
+ }),
+ )
+ })
+ })
+ })
+
+ describe('Branch Coverage - onAuth callback', () => {
+ beforeEach(() => {
+ mockGetFormValues.mockReturnValue({
+ isCheckValidated: true,
+ values: { __oauth_client__: 'custom' },
+ })
+ })
+
+ it('should call onAuth when provided and Save and Auth is clicked', async () => {
+ const pluginPayload = createPluginPayload()
+ const onAuth = vi.fn().mockResolvedValue(undefined)
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveAndAuth'))
+
+ await waitFor(() => {
+ expect(onAuth).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call onAuth when not provided', async () => {
+ const pluginPayload = createPluginPayload()
+ mockSetPluginOAuthCustomClient.mockResolvedValue({})
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ fireEvent.click(screen.getByText('plugin.auth.saveAndAuth'))
+
+ await waitFor(() => {
+ expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled()
+ })
+ // No onAuth to call, but should not throw
+ })
+ })
+
+ describe('Branch Coverage - disabled states', () => {
+ it('should disable buttons when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.saveAndAuth').closest('button')).toBeDisabled()
+ expect(screen.getByText('plugin.auth.saveOnly').closest('button')).toBeDisabled()
+ })
+
+ it('should disable Remove button when editValues is undefined', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Remove button should exist but be disabled
+ const removeButton = screen.queryByText('common.operation.remove')
+ if (removeButton) {
+ expect(removeButton.closest('button')).toBeDisabled()
+ }
+ })
+
+ it('should disable Remove button when disabled prop is true', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithOAuthClient: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const removeButton = screen.getByText('common.operation.remove')
+ expect(removeButton.closest('button')).toBeDisabled()
+ })
+ })
+
+ describe('Branch Coverage - pluginPayload.detail', () => {
+ it('should render ReadmeEntrance when pluginPayload has detail', () => {
+ const pluginPayload = createPluginPayload({
+ detail: {
+ name: 'test-plugin',
+ label: { en_US: 'Test Plugin' },
+ } as unknown as PluginPayload['detail'],
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // ReadmeEntrance should be rendered (it's mocked in vitest.setup)
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+
+ it('should not render ReadmeEntrance when pluginPayload has no detail', () => {
+ const pluginPayload = createPluginPayload({ detail: undefined })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+
+ describe('Branch Coverage - footerSlot conditions', () => {
+ it('should show Remove button only when __oauth_client__=custom AND hasOriginalClientParams=true', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithCustomOAuth: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
+ })
+
+ it('should not show Remove button when hasOriginalClientParams=false', () => {
+ const pluginPayload = createPluginPayload()
+ const schemasWithCustomOAuth: FormSchema[] = [
+ {
+ name: '__oauth_client__',
+ label: 'OAuth Client',
+ type: 'radio' as FormSchema['type'],
+ options: [
+ { label: 'Default', value: 'default' },
+ { label: 'Custom', value: 'custom' },
+ ],
+ default: 'custom',
+ required: false,
+ },
+ ...defaultSchemas,
+ ]
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be a memoized component', async () => {
+ const OAuthClientSettingsDefault = (await import('./oauth-client-settings')).default
+ expect(typeof OAuthClientSettingsDefault).toBe('object')
+ })
+ })
+})
+
+// ==================== Integration Tests ====================
+describe('Authorize Components Integration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialSchema.mockReturnValue([
+ createFormSchema({ name: 'api_key', label: 'API Key' }),
+ ])
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+ })
+
+ describe('AddApiKeyButton -> ApiKeyModal Flow', () => {
+ it('should open ApiKeyModal when AddApiKeyButton is clicked', async () => {
+ const AddApiKeyButton = (await import('./add-api-key-button')).default
+ const pluginPayload = createPluginPayload()
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('AddOAuthButton -> OAuthClientSettings Flow', () => {
+ it('should open OAuthClientSettings when setup button is clicked', async () => {
+ const AddOAuthButton = (await import('./add-oauth-button')).default
+ const pluginPayload = createPluginPayload()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ redirect_uri: 'https://example.com/callback',
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
+
+ await waitFor(() => {
+ expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
new file mode 100644
index 0000000000..354ef8eeea
--- /dev/null
+++ b/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx
@@ -0,0 +1,786 @@
+import type { ReactNode } from 'react'
+import type { PluginPayload } from '../types'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { AuthCategory } from '../types'
+import Authorize from './index'
+
+// Create a wrapper with QueryClientProvider for real component testing
+const createTestQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ })
+
+const createWrapper = () => {
+ const testQueryClient = createTestQueryClient()
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+// Mock API hooks - only mock network-related hooks
+const mockGetPluginOAuthClientSchema = vi.fn()
+
+vi.mock('../hooks/use-credential', () => ({
+ useGetPluginOAuthUrlHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }),
+ }),
+ useGetPluginOAuthClientSchemaHook: () => ({
+ data: mockGetPluginOAuthClientSchema(),
+ isLoading: false,
+ }),
+ useSetPluginOAuthCustomClientHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useDeletePluginOAuthCustomClientHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useInvalidPluginOAuthClientSchemaHook: () => vi.fn(),
+ useAddPluginCredentialHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useUpdatePluginCredentialHook: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ }),
+ useGetPluginCredentialSchemaHook: () => ({
+ data: [],
+ isLoading: false,
+ }),
+}))
+
+// Mock openOAuthPopup - window operations
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: vi.fn(),
+}))
+
+// Mock service/use-triggers - API service
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerPluginDynamicOptions: () => ({
+ data: { options: [] },
+ isLoading: false,
+ }),
+ useTriggerPluginDynamicOptionsInfo: () => ({
+ data: null,
+ isLoading: false,
+ }),
+ useInvalidTriggerDynamicOptions: () => vi.fn(),
+}))
+
+// Factory function for creating test PluginPayload
+const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ ...overrides,
+})
+
+describe('Authorize', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // No buttons should be rendered
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ // Container should only have wrapper element
+ expect(container.querySelector('.flex')).toBeInTheDocument()
+ })
+
+ it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // OAuth button should exist (either configured or setup button)
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render only API Key button when canApiKey is true and canOAuth is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render both OAuth and API Key buttons when both are true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBe(2)
+ })
+
+ it('should render divider when showDivider is true and both buttons are shown', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('or')).toBeInTheDocument()
+ })
+
+ it('should not render divider when showDivider is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+
+ it('should not render divider when only one button type is shown', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+
+ it('should render divider by default (showDivider defaults to true)', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('or')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Props Testing ====================
+ describe('Props Testing', () => {
+ describe('theme prop', () => {
+ it('should render buttons with secondary theme variant when theme is secondary', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button.className).toContain('btn-secondary')
+ })
+ })
+ })
+
+ describe('disabled prop', () => {
+ it('should disable OAuth button when disabled is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should disable API Key button when disabled is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should not disable buttons when disabled is false', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button).not.toBeDisabled()
+ })
+ })
+ })
+
+ describe('notAllowCustomCredential prop', () => {
+ it('should disable OAuth button when notAllowCustomCredential is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should disable API Key button when notAllowCustomCredential is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('should add opacity class when notAllowCustomCredential is true', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const wrappers = container.querySelectorAll('.opacity-50')
+ expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
+ })
+ })
+ })
+
+ // ==================== Button Text Variations ====================
+ describe('Button Text Variations', () => {
+ it('should show correct OAuth text based on canApiKey', () => {
+ const pluginPayload = createPluginPayload()
+
+ // When canApiKey is false, should show "useOAuthAuth"
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toHaveTextContent('plugin.auth')
+
+ // When canApiKey is true, button text changes
+ rerender(
+ ,
+ )
+
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBe(2)
+ })
+ })
+
+ // ==================== Memoization Dependencies ====================
+ describe('Memoization and Re-rendering', () => {
+ it('should maintain stable props across re-renders with same dependencies', () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const initialButtonCount = screen.getAllByRole('button').length
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(initialButtonCount)
+ })
+
+ it('should update when canApiKey changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+ })
+
+ it('should update when canOAuth changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+ })
+
+ it('should update button variant when theme changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttonPrimary = screen.getByRole('button')
+ // Primary theme with canOAuth=false should have primary variant
+ expect(buttonPrimary.className).toContain('btn-primary')
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByRole('button').className).toContain('btn-secondary')
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle undefined pluginPayload properties gracefully', () => {
+ const pluginPayload: PluginPayload = {
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ providerType: undefined,
+ detail: undefined,
+ }
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle all auth categories', () => {
+ const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger]
+
+ categories.forEach((category) => {
+ const pluginPayload = createPluginPayload({ category })
+
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+
+ unmount()
+ })
+ })
+
+ it('should handle empty string provider', () => {
+ const pluginPayload = createPluginPayload({ provider: '' })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle both disabled and notAllowCustomCredential together', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button).toBeDisabled()
+ })
+ })
+ })
+
+ // ==================== Component Memoization ====================
+ describe('Component Memoization', () => {
+ it('should be a memoized component (exported with memo)', async () => {
+ const AuthorizeDefault = (await import('./index')).default
+ expect(AuthorizeDefault).toBeDefined()
+ // memo wrapped components are React elements with $$typeof
+ expect(typeof AuthorizeDefault).toBe('object')
+ })
+
+ it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
+ const pluginPayload = createPluginPayload()
+ const onUpdate = vi.fn()
+
+ const { rerender, container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const initialOpacityElements = container.querySelectorAll('.opacity-50').length
+
+ rerender(
+ ,
+ )
+
+ expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
+ })
+
+ it('should update wrapper when notAllowCustomCredential changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender, container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(container.querySelectorAll('.opacity-50').length).toBe(0)
+
+ rerender(
+ ,
+ )
+
+ expect(container.querySelectorAll('.opacity-50').length).toBe(1)
+ })
+ })
+
+ // ==================== Integration with pluginPayload ====================
+ describe('pluginPayload Integration', () => {
+ it('should pass pluginPayload to OAuth button', () => {
+ const pluginPayload = createPluginPayload({
+ provider: 'special-provider',
+ category: AuthCategory.model,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should pass pluginPayload to API Key button', () => {
+ const pluginPayload = createPluginPayload({
+ provider: 'another-provider',
+ category: AuthCategory.datasource,
+ })
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should handle pluginPayload with detail property', () => {
+ const pluginPayload = createPluginPayload({
+ detail: {
+ plugin_id: 'test-plugin',
+ name: 'Test Plugin',
+ } as PluginPayload['detail'],
+ })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ // ==================== Conditional Rendering Scenarios ====================
+ describe('Conditional Rendering Scenarios', () => {
+ it('should handle rapid prop changes', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getAllByRole('button').length).toBe(2)
+
+ rerender()
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender()
+ expect(screen.getAllByRole('button').length).toBe(1)
+
+ rerender()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should correctly toggle divider visibility based on button combinations', () => {
+ const pluginPayload = createPluginPayload()
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('or')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Accessibility ====================
+ describe('Accessibility', () => {
+ it('should have accessible button elements', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBe(2)
+ })
+
+ it('should indicate disabled state for accessibility', () => {
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button).toBeDisabled()
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-auth/index.spec.tsx b/web/app/components/plugins/plugin-auth/index.spec.tsx
new file mode 100644
index 0000000000..97b5094e99
--- /dev/null
+++ b/web/app/components/plugins/plugin-auth/index.spec.tsx
@@ -0,0 +1,2035 @@
+import type { ReactNode } from 'react'
+import type { Credential, PluginPayload } from './types'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { AuthCategory, CredentialTypeEnum } from './types'
+
+// ==================== Mock Setup ====================
+
+// Mock API hooks for credential operations
+const mockGetPluginCredentialInfo = vi.fn()
+const mockDeletePluginCredential = vi.fn()
+const mockSetPluginDefaultCredential = vi.fn()
+const mockUpdatePluginCredential = vi.fn()
+const mockInvalidPluginCredentialInfo = vi.fn()
+const mockGetPluginOAuthUrl = vi.fn()
+const mockGetPluginOAuthClientSchema = vi.fn()
+const mockSetPluginOAuthCustomClient = vi.fn()
+const mockDeletePluginOAuthCustomClient = vi.fn()
+const mockInvalidPluginOAuthClientSchema = vi.fn()
+const mockAddPluginCredential = vi.fn()
+const mockGetPluginCredentialSchema = vi.fn()
+const mockInvalidToolsByType = vi.fn()
+
+vi.mock('@/service/use-plugins-auth', () => ({
+ useGetPluginCredentialInfo: (url: string) => ({
+ data: url ? mockGetPluginCredentialInfo() : undefined,
+ isLoading: false,
+ }),
+ useDeletePluginCredential: () => ({
+ mutateAsync: mockDeletePluginCredential,
+ }),
+ useSetPluginDefaultCredential: () => ({
+ mutateAsync: mockSetPluginDefaultCredential,
+ }),
+ useUpdatePluginCredential: () => ({
+ mutateAsync: mockUpdatePluginCredential,
+ }),
+ useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo,
+ useGetPluginOAuthUrl: () => ({
+ mutateAsync: mockGetPluginOAuthUrl,
+ }),
+ useGetPluginOAuthClientSchema: () => ({
+ data: mockGetPluginOAuthClientSchema(),
+ isLoading: false,
+ }),
+ useSetPluginOAuthCustomClient: () => ({
+ mutateAsync: mockSetPluginOAuthCustomClient,
+ }),
+ useDeletePluginOAuthCustomClient: () => ({
+ mutateAsync: mockDeletePluginOAuthCustomClient,
+ }),
+ useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema,
+ useAddPluginCredential: () => ({
+ mutateAsync: mockAddPluginCredential,
+ }),
+ useGetPluginCredentialSchema: () => ({
+ data: mockGetPluginCredentialSchema(),
+ isLoading: false,
+ }),
+}))
+
+vi.mock('@/service/use-tools', () => ({
+ useInvalidToolsByType: () => mockInvalidToolsByType,
+}))
+
+// Mock AppContext
+const mockIsCurrentWorkspaceManager = vi.fn()
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
+ }),
+}))
+
+// Mock toast context
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ }),
+}))
+
+// Mock openOAuthPopup
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: vi.fn(),
+}))
+
+// Mock service/use-triggers
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerPluginDynamicOptions: () => ({
+ data: { options: [] },
+ isLoading: false,
+ }),
+ useTriggerPluginDynamicOptionsInfo: () => ({
+ data: null,
+ isLoading: false,
+ }),
+ useInvalidTriggerDynamicOptions: () => vi.fn(),
+}))
+
+// ==================== Test Utilities ====================
+
+const createTestQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ })
+
+const createWrapper = () => {
+ const testQueryClient = createTestQueryClient()
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+// Factory functions for test data
+const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ ...overrides,
+})
+
+const createCredential = (overrides: Partial = {}): Credential => ({
+ id: 'test-credential-id',
+ name: 'Test Credential',
+ provider: 'test-provider',
+ credential_type: CredentialTypeEnum.API_KEY,
+ is_default: false,
+ credentials: { api_key: 'test-key' },
+ ...overrides,
+})
+
+const createCredentialList = (count: number, overrides: Partial[] = []): Credential[] => {
+ return Array.from({ length: count }, (_, i) => createCredential({
+ id: `credential-${i}`,
+ name: `Credential ${i}`,
+ is_default: i === 0,
+ ...overrides[i],
+ }))
+}
+
+// ==================== Index Exports Tests ====================
+describe('Index Exports', () => {
+ it('should export all required components and hooks', async () => {
+ const exports = await import('./index')
+
+ expect(exports.AddApiKeyButton).toBeDefined()
+ expect(exports.AddOAuthButton).toBeDefined()
+ expect(exports.ApiKeyModal).toBeDefined()
+ expect(exports.Authorized).toBeDefined()
+ expect(exports.AuthorizedInDataSourceNode).toBeDefined()
+ expect(exports.AuthorizedInNode).toBeDefined()
+ expect(exports.usePluginAuth).toBeDefined()
+ expect(exports.PluginAuth).toBeDefined()
+ expect(exports.PluginAuthInAgent).toBeDefined()
+ expect(exports.PluginAuthInDataSourceNode).toBeDefined()
+ })
+
+ it('should export AuthCategory enum', async () => {
+ const exports = await import('./index')
+
+ expect(exports.AuthCategory).toBeDefined()
+ expect(exports.AuthCategory.tool).toBe('tool')
+ expect(exports.AuthCategory.datasource).toBe('datasource')
+ expect(exports.AuthCategory.model).toBe('model')
+ expect(exports.AuthCategory.trigger).toBe('trigger')
+ })
+
+ it('should export CredentialTypeEnum', async () => {
+ const exports = await import('./index')
+
+ expect(exports.CredentialTypeEnum).toBeDefined()
+ expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2')
+ expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key')
+ })
+})
+
+// ==================== Types Tests ====================
+describe('Types', () => {
+ describe('AuthCategory enum', () => {
+ it('should have correct values', () => {
+ expect(AuthCategory.tool).toBe('tool')
+ expect(AuthCategory.datasource).toBe('datasource')
+ expect(AuthCategory.model).toBe('model')
+ expect(AuthCategory.trigger).toBe('trigger')
+ })
+
+ it('should have exactly 4 categories', () => {
+ const values = Object.values(AuthCategory)
+ expect(values).toHaveLength(4)
+ })
+ })
+
+ describe('CredentialTypeEnum', () => {
+ it('should have correct values', () => {
+ expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
+ expect(CredentialTypeEnum.API_KEY).toBe('api-key')
+ })
+
+ it('should have exactly 2 types', () => {
+ const values = Object.values(CredentialTypeEnum)
+ expect(values).toHaveLength(2)
+ })
+ })
+
+ describe('Credential type', () => {
+ it('should allow creating valid credentials', () => {
+ const credential: Credential = {
+ id: 'test-id',
+ name: 'Test',
+ provider: 'test-provider',
+ is_default: true,
+ }
+ expect(credential.id).toBe('test-id')
+ expect(credential.is_default).toBe(true)
+ })
+
+ it('should allow optional fields', () => {
+ const credential: Credential = {
+ id: 'test-id',
+ name: 'Test',
+ provider: 'test-provider',
+ is_default: false,
+ credential_type: CredentialTypeEnum.API_KEY,
+ credentials: { key: 'value' },
+ isWorkspaceDefault: true,
+ from_enterprise: false,
+ not_allowed_to_use: false,
+ }
+ expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY)
+ expect(credential.isWorkspaceDefault).toBe(true)
+ })
+ })
+
+ describe('PluginPayload type', () => {
+ it('should allow creating valid plugin payload', () => {
+ const payload: PluginPayload = {
+ category: AuthCategory.tool,
+ provider: 'test-provider',
+ }
+ expect(payload.category).toBe(AuthCategory.tool)
+ })
+
+ it('should allow optional fields', () => {
+ const payload: PluginPayload = {
+ category: AuthCategory.datasource,
+ provider: 'test-provider',
+ providerType: 'builtin',
+ detail: undefined,
+ }
+ expect(payload.providerType).toBe('builtin')
+ })
+ })
+})
+
+// ==================== Utils Tests ====================
+describe('Utils', () => {
+ describe('transformFormSchemasSecretInput', () => {
+ it('should transform secret input values to hidden format', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key', 'secret_token']
+ const values = {
+ api_key: 'actual-key',
+ secret_token: 'actual-token',
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBe('[__HIDDEN__]')
+ expect(result.secret_token).toBe('[__HIDDEN__]')
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should not transform empty secret values', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {
+ api_key: '',
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBe('')
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should not transform undefined secret values', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBeUndefined()
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should handle empty secret names array', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames: string[] = []
+ const values = {
+ api_key: 'actual-key',
+ public_key: 'public-value',
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(result.api_key).toBe('actual-key')
+ expect(result.public_key).toBe('public-value')
+ })
+
+ it('should handle empty values object', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {}
+
+ const result = transformFormSchemasSecretInput(secretNames, values)
+
+ expect(Object.keys(result)).toHaveLength(0)
+ })
+
+ it('should preserve original values object immutably', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key']
+ const values = {
+ api_key: 'actual-key',
+ public_key: 'public-value',
+ }
+
+ transformFormSchemasSecretInput(secretNames, values)
+
+ expect(values.api_key).toBe('actual-key')
+ })
+
+ it('should handle null-ish values correctly', async () => {
+ const { transformFormSchemasSecretInput } = await import('./utils')
+
+ const secretNames = ['api_key', 'null_key']
+ const values = {
+ api_key: null,
+ null_key: 0,
+ }
+
+ const result = transformFormSchemasSecretInput(secretNames, values as Record)
+
+ // null is falsy, so it won't be transformed
+ expect(result.api_key).toBe(null)
+ // 0 is falsy, so it won't be transformed
+ expect(result.null_key).toBe(0)
+ })
+ })
+})
+
+// ==================== useGetApi Hook Tests ====================
+describe('useGetApi Hook', () => {
+ describe('tool category', () => {
+ it('should return correct API endpoints for tool category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: 'test-tool',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin/test-tool/credential/info')
+ expect(apiMap.setDefaultCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/default-credential')
+ expect(apiMap.getCredentials).toBe('/workspaces/current/tool-provider/builtin/test-tool/credentials')
+ expect(apiMap.addCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/add')
+ expect(apiMap.updateCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/update')
+ expect(apiMap.deleteCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/delete')
+ expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-tool/tool/authorization-url')
+ expect(apiMap.getOauthClientSchema).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/client-schema')
+ expect(apiMap.setCustomOauthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client')
+ expect(apiMap.deleteCustomOAuthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client')
+ })
+
+ it('should return getCredentialSchema function for tool category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: 'test-tool',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe(
+ '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/api-key',
+ )
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.OAUTH2)).toBe(
+ '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/oauth2',
+ )
+ })
+ })
+
+ describe('datasource category', () => {
+ it('should return correct API endpoints for datasource category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.datasource,
+ provider: 'test-datasource',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('')
+ expect(apiMap.setDefaultCredential).toBe('/auth/plugin/datasource/test-datasource/default')
+ expect(apiMap.getCredentials).toBe('/auth/plugin/datasource/test-datasource')
+ expect(apiMap.addCredential).toBe('/auth/plugin/datasource/test-datasource')
+ expect(apiMap.updateCredential).toBe('/auth/plugin/datasource/test-datasource/update')
+ expect(apiMap.deleteCredential).toBe('/auth/plugin/datasource/test-datasource/delete')
+ expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-datasource/datasource/get-authorization-url')
+ expect(apiMap.getOauthClientSchema).toBe('')
+ expect(apiMap.setCustomOauthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client')
+ expect(apiMap.deleteCustomOAuthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client')
+ })
+
+ it('should return empty string for getCredentialSchema in datasource', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.datasource,
+ provider: 'test-datasource',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('')
+ })
+ })
+
+ describe('other categories', () => {
+ it('should return empty strings for model category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.model,
+ provider: 'test-model',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('')
+ expect(apiMap.setDefaultCredential).toBe('')
+ expect(apiMap.getCredentials).toBe('')
+ expect(apiMap.addCredential).toBe('')
+ expect(apiMap.updateCredential).toBe('')
+ expect(apiMap.deleteCredential).toBe('')
+ expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('')
+ })
+
+ it('should return empty strings for trigger category', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.trigger,
+ provider: 'test-trigger',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('')
+ expect(apiMap.setDefaultCredential).toBe('')
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty provider', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: '',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin//credential/info')
+ })
+
+ it('should handle special characters in provider name', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ category: AuthCategory.tool,
+ provider: 'test-provider_v2',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toContain('test-provider_v2')
+ })
+ })
+})
+
+// ==================== usePluginAuth Hook Tests ====================
+describe('usePluginAuth Hook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [],
+ allow_custom_token: true,
+ })
+ })
+
+ it('should return isAuthorized false when no credentials', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(false)
+ expect(result.current.credentials).toHaveLength(0)
+ })
+
+ it('should return isAuthorized true when credentials exist', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(true)
+ expect(result.current.credentials).toHaveLength(1)
+ })
+
+ it('should return canOAuth true when oauth2 is supported', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.OAUTH2],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.canOAuth).toBe(true)
+ expect(result.current.canApiKey).toBe(false)
+ })
+
+ it('should return canApiKey true when api-key is supported', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.canOAuth).toBe(false)
+ expect(result.current.canApiKey).toBe(true)
+ })
+
+ it('should return both canOAuth and canApiKey when both supported', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.OAUTH2, CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.canOAuth).toBe(true)
+ expect(result.current.canApiKey).toBe(true)
+ })
+
+ it('should return disabled true when user is not workspace manager', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockIsCurrentWorkspaceManager.mockReturnValue(false)
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.disabled).toBe(true)
+ })
+
+ it('should return disabled false when user is workspace manager', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.disabled).toBe(false)
+ })
+
+ it('should return notAllowCustomCredential based on allow_custom_token', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [],
+ allow_custom_token: false,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.notAllowCustomCredential).toBe(true)
+ })
+
+ it('should return invalidPluginCredentialInfo function', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.invalidPluginCredentialInfo).toBe('function')
+ })
+
+ it('should not fetch when enable is false', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, false), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(false)
+ expect(result.current.credentials).toHaveLength(0)
+ })
+})
+
+// ==================== usePluginAuthAction Hook Tests ====================
+describe('usePluginAuthAction Hook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDeletePluginCredential.mockResolvedValue({})
+ mockSetPluginDefaultCredential.mockResolvedValue({})
+ mockUpdatePluginCredential.mockResolvedValue({})
+ })
+
+ it('should return all action handlers', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.doingAction).toBe(false)
+ expect(typeof result.current.handleSetDoingAction).toBe('function')
+ expect(typeof result.current.openConfirm).toBe('function')
+ expect(typeof result.current.closeConfirm).toBe('function')
+ expect(result.current.deleteCredentialId).toBe(null)
+ expect(typeof result.current.setDeleteCredentialId).toBe('function')
+ expect(typeof result.current.handleConfirm).toBe('function')
+ expect(result.current.editValues).toBe(null)
+ expect(typeof result.current.setEditValues).toBe('function')
+ expect(typeof result.current.handleEdit).toBe('function')
+ expect(typeof result.current.handleRemove).toBe('function')
+ expect(typeof result.current.handleSetDefault).toBe('function')
+ expect(typeof result.current.handleRename).toBe('function')
+ })
+
+ it('should open and close confirm dialog', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.openConfirm('test-credential-id')
+ })
+
+ expect(result.current.deleteCredentialId).toBe('test-credential-id')
+
+ act(() => {
+ result.current.closeConfirm()
+ })
+
+ expect(result.current.deleteCredentialId).toBe(null)
+ })
+
+ it('should handle edit with values', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ const editValues = { key: 'value' }
+
+ act(() => {
+ result.current.handleEdit('test-id', editValues)
+ })
+
+ expect(result.current.editValues).toEqual(editValues)
+ })
+
+ it('should handle confirm delete', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const onUpdate = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.openConfirm('test-credential-id')
+ })
+
+ await act(async () => {
+ await result.current.handleConfirm()
+ })
+
+ expect(mockDeletePluginCredential).toHaveBeenCalledWith({ credential_id: 'test-credential-id' })
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.api.actionSuccess',
+ })
+ expect(onUpdate).toHaveBeenCalled()
+ expect(result.current.deleteCredentialId).toBe(null)
+ })
+
+ it('should not confirm delete when no credential id', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handleConfirm()
+ })
+
+ expect(mockDeletePluginCredential).not.toHaveBeenCalled()
+ })
+
+ it('should handle set default', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const onUpdate = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handleSetDefault('test-credential-id')
+ })
+
+ expect(mockSetPluginDefaultCredential).toHaveBeenCalledWith('test-credential-id')
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.api.actionSuccess',
+ })
+ expect(onUpdate).toHaveBeenCalled()
+ })
+
+ it('should handle rename', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const onUpdate = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ await result.current.handleRename({
+ credential_id: 'test-credential-id',
+ name: 'New Name',
+ })
+ })
+
+ expect(mockUpdatePluginCredential).toHaveBeenCalledWith({
+ credential_id: 'test-credential-id',
+ name: 'New Name',
+ })
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.api.actionSuccess',
+ })
+ expect(onUpdate).toHaveBeenCalled()
+ })
+
+ it('should prevent concurrent actions', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.handleSetDoingAction(true)
+ })
+
+ act(() => {
+ result.current.openConfirm('test-credential-id')
+ })
+
+ await act(async () => {
+ await result.current.handleConfirm()
+ })
+
+ // Should not call delete when already doing action
+ expect(mockDeletePluginCredential).not.toHaveBeenCalled()
+ })
+
+ it('should handle remove after edit', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.handleEdit('test-credential-id', { key: 'value' })
+ })
+
+ act(() => {
+ result.current.handleRemove()
+ })
+
+ expect(result.current.deleteCredentialId).toBe('test-credential-id')
+ })
+})
+
+// ==================== PluginAuth Component Tests ====================
+describe('PluginAuth Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ it('should render Authorize when not authorized', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Should render authorize button
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render Authorized when authorized and no children', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Should render authorized content
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render children when authorized and children provided', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+
+ Custom Content
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByTestId('custom-children')).toBeInTheDocument()
+ expect(screen.getByText('Custom Content')).toBeInTheDocument()
+ })
+
+ it('should apply className when not authorized', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should not apply className when authorized', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(container.firstChild).not.toHaveClass('custom-class')
+ })
+
+ it('should be memoized', async () => {
+ const PluginAuthModule = await import('./plugin-auth')
+ expect(typeof PluginAuthModule.default).toBe('object')
+ })
+})
+
+// ==================== PluginAuthInAgent Component Tests ====================
+describe('PluginAuthInAgent Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ it('should render Authorize when not authorized', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render Authorized with workspace default when authorized', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument()
+ })
+
+ it('should show credential name when credentialId is provided', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const credential = createCredential({ id: 'selected-id', name: 'Selected Credential' })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('Selected Credential')).toBeInTheDocument()
+ })
+
+ it('should show auth removed when credential not found', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument()
+ })
+
+ it('should show unavailable when credential is not allowed to use', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const credential = createCredential({
+ id: 'unavailable-id',
+ name: 'Unavailable Credential',
+ not_allowed_to_use: true,
+ from_enterprise: false,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Check that button text contains unavailable
+ const button = screen.getByRole('button')
+ expect(button.textContent).toContain('plugin.auth.unavailable')
+ })
+
+ it('should call onAuthorizationItemClick when item is clicked', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click to open popup
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+
+ // Verify popup is opened (there will be multiple buttons after opening)
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
+ })
+
+ it('should trigger handleAuthorizationItemClick and close popup when authorization item is clicked', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const credential = createCredential({ id: 'test-cred-id', name: 'Test Credential' })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click trigger button to open popup
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ // Find and click the workspace default item in the dropdown
+ // There will be multiple elements with this text, we need the one in the popup (not the trigger)
+ const workspaceDefaultItems = screen.getAllByText('plugin.auth.workspaceDefault')
+ // The second one is in the popup list (first one is the trigger button)
+ const popupItem = workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0]
+ fireEvent.click(popupItem)
+
+ // Verify onAuthorizationItemClick was called with empty string for workspace default
+ expect(onAuthorizationItemClick).toHaveBeenCalledWith('')
+ })
+
+ it('should call onAuthorizationItemClick with credential id when specific credential is clicked', async () => {
+ const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const credential = createCredential({
+ id: 'specific-cred-id',
+ name: 'Specific Credential',
+ credential_type: CredentialTypeEnum.API_KEY,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click trigger button to open popup
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ // Find and click the specific credential item - there might be multiple "Specific Credential" texts
+ const credentialItems = screen.getAllByText('Specific Credential')
+ // Click the one in the popup (usually the last one if trigger shows different text)
+ const popupItem = credentialItems[credentialItems.length - 1]
+ fireEvent.click(popupItem)
+
+ // Verify onAuthorizationItemClick was called with the credential id
+ expect(onAuthorizationItemClick).toHaveBeenCalledWith('specific-cred-id')
+ })
+
+ it('should be memoized', async () => {
+ const PluginAuthInAgentModule = await import('./plugin-auth-in-agent')
+ expect(typeof PluginAuthInAgentModule.default).toBe('object')
+ })
+})
+
+// ==================== PluginAuthInDataSourceNode Component Tests ====================
+describe('PluginAuthInDataSourceNode Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render connect button when not authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(screen.getByText('common.integrations.connect')).toBeInTheDocument()
+ })
+
+ it('should call onJumpToDataSourcePage when connect button is clicked', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button'))
+ expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render children when authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+
+ Authorized Content
+ ,
+ )
+
+ expect(screen.getByTestId('children-content')).toBeInTheDocument()
+ expect(screen.getByText('Authorized Content')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should not render connect button when authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should not render children when not authorized', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+
+ Authorized Content
+ ,
+ )
+
+ expect(screen.queryByTestId('children-content')).not.toBeInTheDocument()
+ })
+
+ it('should handle undefined isAuthorized (falsy)', async () => {
+ const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+
+ Content
+ ,
+ )
+
+ // isAuthorized is undefined, which is falsy, so connect button should be shown
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.queryByTestId('children-content')).not.toBeInTheDocument()
+ })
+
+ it('should be memoized', async () => {
+ const PluginAuthInDataSourceNodeModule = await import('./plugin-auth-in-datasource-node')
+ expect(typeof PluginAuthInDataSourceNodeModule.default).toBe('object')
+ })
+})
+
+// ==================== AuthorizedInDataSourceNode Component Tests ====================
+describe('AuthorizedInDataSourceNode Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render with singular authorization text when authorizationsNum is 1', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument()
+ })
+
+ it('should render with plural authorizations text when authorizationsNum > 1', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('plugin.auth.authorizations')).toBeInTheDocument()
+ })
+
+ it('should call onJumpToDataSourcePage when button is clicked', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const onJumpToDataSourcePage = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button'))
+ expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render with green indicator', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ const { container } = render(
+ ,
+ )
+
+ // Check that indicator component is rendered
+ expect(container.querySelector('.mr-1\\.5')).toBeInTheDocument()
+ })
+
+ it('should handle authorizationsNum of 0', async () => {
+ const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default
+
+ render(
+ ,
+ )
+
+ // 0 is not > 1, so should show singular
+ expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument()
+ })
+
+ it('should be memoized', async () => {
+ const AuthorizedInDataSourceNodeModule = await import('./authorized-in-data-source-node')
+ expect(typeof AuthorizedInDataSourceNodeModule.default).toBe('object')
+ })
+})
+
+// ==================== AuthorizedInNode Component Tests ====================
+describe('AuthorizedInNode Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential({ is_default: true })],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ it('should render with workspace default when no credentialId', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument()
+ })
+
+ it('should render credential name when credentialId matches', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const credential = createCredential({ id: 'selected-id', name: 'My Credential' })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('My Credential')).toBeInTheDocument()
+ })
+
+ it('should show auth removed when credentialId not found', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument()
+ })
+
+ it('should show unavailable when credential is not allowed', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const credential = createCredential({
+ id: 'unavailable-id',
+ not_allowed_to_use: true,
+ from_enterprise: false,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Check that button text contains unavailable
+ const button = screen.getByRole('button')
+ expect(button.textContent).toContain('plugin.auth.unavailable')
+ })
+
+ it('should show unavailable when default credential is not allowed', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const credential = createCredential({
+ is_default: true,
+ not_allowed_to_use: true,
+ })
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [credential],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Check that button text contains unavailable
+ const button = screen.getByRole('button')
+ expect(button.textContent).toContain('plugin.auth.unavailable')
+ })
+
+ it('should call onAuthorizationItemClick when clicking', async () => {
+ const AuthorizedInNode = (await import('./authorized-in-node')).default
+
+ const onAuthorizationItemClick = vi.fn()
+ const pluginPayload = createPluginPayload()
+
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Click to open the popup
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+
+ // The popup should be open now - there will be multiple buttons after opening
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
+ })
+
+ it('should be memoized', async () => {
+ const AuthorizedInNodeModule = await import('./authorized-in-node')
+ expect(typeof AuthorizedInNodeModule.default).toBe('object')
+ })
+})
+
+// ==================== useCredential Hooks Tests ====================
+describe('useCredential Hooks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [],
+ allow_custom_token: true,
+ })
+ })
+
+ describe('useGetPluginCredentialInfoHook', () => {
+ it('should return credential info when enabled', async () => {
+ const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential')
+
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [createCredential()],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.data).toBeDefined()
+ expect(result.current.data?.credentials).toHaveLength(1)
+ })
+
+ it('should not fetch when disabled', async () => {
+ const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, false), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.data).toBeUndefined()
+ })
+ })
+
+ describe('useDeletePluginCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useDeletePluginCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useDeletePluginCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useInvalidPluginCredentialInfoHook', () => {
+ it('should return invalidation function that calls both invalidators', async () => {
+ const { useInvalidPluginCredentialInfoHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload({ providerType: 'builtin' })
+
+ const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current).toBe('function')
+
+ result.current()
+
+ expect(mockInvalidPluginCredentialInfo).toHaveBeenCalled()
+ expect(mockInvalidToolsByType).toHaveBeenCalled()
+ })
+ })
+
+ describe('useSetPluginDefaultCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useSetPluginDefaultCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useSetPluginDefaultCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useGetPluginCredentialSchemaHook', () => {
+ it('should return schema data', async () => {
+ const { useGetPluginCredentialSchemaHook } = await import('./hooks/use-credential')
+
+ mockGetPluginCredentialSchema.mockReturnValue([{ name: 'api_key', type: 'string' }])
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(
+ () => useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY),
+ { wrapper: createWrapper() },
+ )
+
+ expect(result.current.data).toBeDefined()
+ })
+ })
+
+ describe('useAddPluginCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useAddPluginCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useAddPluginCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useUpdatePluginCredentialHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useUpdatePluginCredentialHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useUpdatePluginCredentialHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useGetPluginOAuthUrlHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useGetPluginOAuthUrlHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginOAuthUrlHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useGetPluginOAuthClientSchemaHook', () => {
+ it('should return schema data', async () => {
+ const { useGetPluginOAuthClientSchemaHook } = await import('./hooks/use-credential')
+
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useGetPluginOAuthClientSchemaHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.data).toBeDefined()
+ })
+ })
+
+ describe('useSetPluginOAuthCustomClientHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useSetPluginOAuthCustomClientHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useSetPluginOAuthCustomClientHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+
+ describe('useDeletePluginOAuthCustomClientHook', () => {
+ it('should return mutateAsync function', async () => {
+ const { useDeletePluginOAuthCustomClientHook } = await import('./hooks/use-credential')
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => useDeletePluginOAuthCustomClientHook(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ expect(typeof result.current.mutateAsync).toBe('function')
+ })
+ })
+})
+
+// ==================== Edge Cases and Error Handling ====================
+describe('Edge Cases and Error Handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCurrentWorkspaceManager.mockReturnValue(true)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: [],
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+ mockGetPluginOAuthClientSchema.mockReturnValue({
+ schema: [],
+ is_oauth_custom_client_enabled: false,
+ is_system_oauth_params_exists: false,
+ })
+ })
+
+ describe('PluginAuth edge cases', () => {
+ it('should handle empty provider gracefully', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload({ provider: '' })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+
+ it('should handle tool and datasource auth categories with button', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ // Tool and datasource categories should render with API support
+ const categoriesWithApi = [AuthCategory.tool]
+
+ for (const category of categoriesWithApi) {
+ const pluginPayload = createPluginPayload({ category })
+
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+
+ unmount()
+ }
+ })
+
+ it('should handle model and trigger categories without throwing', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ // Model and trigger categories have empty API endpoints, so they render without buttons
+ const categoriesWithoutApi = [AuthCategory.model, AuthCategory.trigger]
+
+ for (const category of categoriesWithoutApi) {
+ const pluginPayload = createPluginPayload({ category })
+
+ expect(() => {
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ unmount()
+ }).not.toThrow()
+ }
+ })
+
+ it('should handle undefined detail', async () => {
+ const PluginAuth = (await import('./plugin-auth')).default
+
+ const pluginPayload = createPluginPayload({ detail: undefined })
+
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('usePluginAuthAction error handling', () => {
+ it('should handle delete error gracefully', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ mockDeletePluginCredential.mockRejectedValue(new Error('Delete failed'))
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ act(() => {
+ result.current.openConfirm('test-id')
+ })
+
+ // Should not throw, error is caught
+ await expect(
+ act(async () => {
+ await result.current.handleConfirm()
+ }),
+ ).rejects.toThrow('Delete failed')
+
+ // Action state should be reset
+ expect(result.current.doingAction).toBe(false)
+ })
+
+ it('should handle set default error gracefully', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ mockSetPluginDefaultCredential.mockRejectedValue(new Error('Set default failed'))
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ await expect(
+ act(async () => {
+ await result.current.handleSetDefault('test-id')
+ }),
+ ).rejects.toThrow('Set default failed')
+
+ expect(result.current.doingAction).toBe(false)
+ })
+
+ it('should handle rename error gracefully', async () => {
+ const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action')
+
+ mockUpdatePluginCredential.mockRejectedValue(new Error('Rename failed'))
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
+ wrapper: createWrapper(),
+ })
+
+ await expect(
+ act(async () => {
+ await result.current.handleRename({ credential_id: 'test-id', name: 'New Name' })
+ }),
+ ).rejects.toThrow('Rename failed')
+
+ expect(result.current.doingAction).toBe(false)
+ })
+ })
+
+ describe('Credential list edge cases', () => {
+ it('should handle large credential lists', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const largeCredentialList = createCredentialList(100)
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: largeCredentialList,
+ supported_credential_types: [CredentialTypeEnum.API_KEY],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.isAuthorized).toBe(true)
+ expect(result.current.credentials).toHaveLength(100)
+ })
+
+ it('should handle mixed credential types', async () => {
+ const { usePluginAuth } = await import('./hooks/use-plugin-auth')
+
+ const mixedCredentials = [
+ createCredential({ id: '1', credential_type: CredentialTypeEnum.API_KEY }),
+ createCredential({ id: '2', credential_type: CredentialTypeEnum.OAUTH2 }),
+ createCredential({ id: '3', credential_type: undefined }),
+ ]
+ mockGetPluginCredentialInfo.mockReturnValue({
+ credentials: mixedCredentials,
+ supported_credential_types: [CredentialTypeEnum.API_KEY, CredentialTypeEnum.OAUTH2],
+ allow_custom_token: true,
+ })
+
+ const pluginPayload = createPluginPayload()
+
+ const { result } = renderHook(() => usePluginAuth(pluginPayload, true), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.credentials).toHaveLength(3)
+ expect(result.current.canOAuth).toBe(true)
+ expect(result.current.canApiKey).toBe(true)
+ })
+ })
+
+ describe('Boundary conditions', () => {
+ it('should handle special characters in provider name', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const pluginPayload = createPluginPayload({
+ provider: 'test-provider_v2.0',
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toContain('test-provider_v2.0')
+ })
+
+ it('should handle very long provider names', async () => {
+ const { useGetApi } = await import('./hooks/use-get-api')
+
+ const longProvider = 'a'.repeat(200)
+ const pluginPayload = createPluginPayload({
+ provider: longProvider,
+ })
+
+ const apiMap = useGetApi(pluginPayload)
+
+ expect(apiMap.getCredentialInfo).toContain(longProvider)
+ })
+ })
+})