diff --git a/web/src/features/system-settings/auth/__tests__/oauth-settings.test.tsx b/web/src/features/system-settings/auth/__tests__/oauth-settings.test.tsx new file mode 100644 index 000000000..b7d0bde33 --- /dev/null +++ b/web/src/features/system-settings/auth/__tests__/oauth-settings.test.tsx @@ -0,0 +1,339 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import axios from 'axios' +import { createInstance, type i18n } from 'i18next' +import { useState, type ComponentProps } from 'react' +import { I18nextProvider } from 'react-i18next' +import { toast } from 'sonner' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import zhTW from '@/i18n/locales/zh-TW.json' +import zh from '@/i18n/locales/zh.json' +import { api } from '@/lib/api' + +import { SettingsPageProvider } from '../../components/settings-page-context' +import { OAuthSection } from '../oauth-section' + +const defaults: ComponentProps['defaultValues'] = { + GitHubOAuthEnabled: false, + GitHubClientId: '', + GitHubClientSecret: '', + 'discord.enabled': false, + 'discord.client_id': '', + 'discord.client_secret': '', + 'oidc.enabled': false, + 'oidc.display_name': '', + 'oidc.client_id': '', + 'oidc.client_secret': '', + 'oidc.well_known': '', + 'oidc.authorization_endpoint': '', + 'oidc.token_endpoint': '', + 'oidc.user_info_endpoint': '', + TelegramOAuthEnabled: false, + 'telegram.client_id': '', + 'telegram.client_secret': '', + LinuxDOOAuthEnabled: false, + LinuxDOClientId: '', + LinuxDOClientSecret: '', + LinuxDOMinimumTrustLevel: '', + WeChatAuthEnabled: false, + WeChatServerAddress: '', + WeChatServerToken: '', + WeChatAccountQRCodeImageURL: '', +} + +let testI18n: i18n + +function Fixture(props: { values?: Partial }) { + const [container, setContainer] = useState(null) + const [client] = useState( + () => new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + ) + + return ( + + +
+ + + + + + ) +} + +async function renderSettings(values?: Partial) { + const route = createRootRoute({ + component: () => , + }) + const router = createRouter({ + routeTree: route, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + render() +} + +beforeEach(async () => { + testI18n = createInstance() + await testI18n.init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: {} }, zh, 'zh-TW': zhTW }, + interpolation: { escapeValue: false }, + }) + vi.spyOn(api, 'put').mockResolvedValue({ data: { success: true } }) +}) + +describe('OAuth settings saves', () => { + it.each([ + [false, 'provider.example.com/discovery'], + [true, 'provider.example.com/discovery'], + [true, 'https://provider.example.com/discovery'], + ])( + 'saves GitHub independently of unchanged OIDC settings (enabled: %s, URL: %s)', + async (enabled, url) => { + const discoveryClient = axios.create() + vi.spyOn(discoveryClient, 'get').mockRejectedValue( + new Error('OIDC unavailable') + ) + const discovery = vi + .spyOn(axios, 'create') + .mockReturnValue(discoveryClient) + const errorToast = vi.spyOn(toast, 'error') + const user = userEvent.setup() + await renderSettings({ 'oidc.enabled': enabled, 'oidc.well_known': url }) + await user.type( + screen.getByRole('textbox', { name: 'Client ID' }), + 'github-client' + ) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + + await waitFor(() => + expect(api.put).toHaveBeenCalledWith('/api/option/', { + key: 'GitHubClientId', + value: 'github-client', + }) + ) + expect(api.put).toHaveBeenCalledTimes(1) + expect(discovery).not.toHaveBeenCalled() + expect(errorToast).not.toHaveBeenCalled() + expect(screen.getByRole('tab', { name: 'GitHub' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() + } + ) + + it('does not discover OIDC when saving an unchanged form', async () => { + const discovery = vi.spyOn(axios, 'create') + const infoToast = vi.spyOn(toast, 'info') + const user = userEvent.setup() + await renderSettings({ + 'oidc.well_known': 'provider.example.com/discovery', + }) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + + expect(infoToast).toHaveBeenCalledWith('No changes to save') + expect(discovery).not.toHaveBeenCalled() + expect(api.put).not.toHaveBeenCalled() + }) + + it('allows disabling OIDC even when its saved discovery URL is invalid', async () => { + const user = userEvent.setup() + await renderSettings({ + 'oidc.enabled': true, + 'oidc.well_known': 'provider.example.com/discovery', + }) + await user.click(screen.getByRole('tab', { name: 'OIDC' })) + await user.click(screen.getByRole('switch', { name: 'Enable OIDC' })) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + + await waitFor(() => + expect(api.put).toHaveBeenCalledWith('/api/option/', { + key: 'oidc.enabled', + value: false, + }) + ) + expect(api.put).toHaveBeenCalledTimes(1) + }) + + it('opens OIDC and marks an edited invalid URL even when saving from the GitHub tab', async () => { + const errorToast = vi.spyOn(toast, 'error') + const user = userEvent.setup() + await renderSettings() + await user.click(screen.getByRole('tab', { name: 'OIDC' })) + await user.type( + screen.getByRole('textbox', { name: 'Well-Known URL' }), + 'provider.example.com/discovery' + ) + await user.click(screen.getByRole('tab', { name: 'GitHub' })) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + + const url = await screen.findByRole('textbox', { name: 'Well-Known URL' }) + expect(url).toHaveAttribute('aria-invalid', 'true') + expect(url).toHaveAccessibleDescription( + expect.stringContaining( + 'Well-Known URL must start with http:// or https://' + ) + ) + expect(api.put).not.toHaveBeenCalled() + expect(errorToast).not.toHaveBeenCalled() + }) + + it('validates the saved discovery URL before enabling OIDC', async () => { + const user = userEvent.setup() + await renderSettings({ + 'oidc.well_known': 'provider.example.com/discovery', + }) + await user.click(screen.getByRole('tab', { name: 'OIDC' })) + await user.click(screen.getByRole('switch', { name: 'Enable OIDC' })) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + + expect( + screen.getByRole('textbox', { name: 'Well-Known URL' }) + ).toHaveAttribute('aria-invalid', 'true') + expect(api.put).not.toHaveBeenCalled() + }) + + it('keeps Save disabled during discovery and persists the discovered endpoints', async () => { + const client = axios.create() + const response = { + data: { + authorization_endpoint: 'https://provider.example.com/authorize', + token_endpoint: 'https://provider.example.com/token', + userinfo_endpoint: 'https://provider.example.com/userinfo', + }, + } + let finishDiscovery!: (value: typeof response) => void + const get = vi.spyOn(client, 'get').mockImplementation( + () => + new Promise((resolve) => { + finishDiscovery = resolve + }) + ) + vi.spyOn(axios, 'create').mockReturnValue(client) + const user = userEvent.setup() + await renderSettings() + await user.click(screen.getByRole('tab', { name: 'OIDC' })) + await user.type( + screen.getByRole('textbox', { name: 'Well-Known URL' }), + 'https://provider.example.com/discovery' + ) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + + await waitFor(() => + expect(get).toHaveBeenCalledWith('https://provider.example.com/discovery') + ) + expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() + expect(api.put).not.toHaveBeenCalled() + finishDiscovery(response) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() + ) + await waitFor(() => expect(api.put).toHaveBeenCalledTimes(4)) + expect(api.put).toHaveBeenCalledWith('/api/option/', { + key: 'oidc.authorization_endpoint', + value: response.data.authorization_endpoint, + }) + expect(api.put).toHaveBeenCalledWith('/api/option/', { + key: 'oidc.token_endpoint', + value: response.data.token_endpoint, + }) + expect(api.put).toHaveBeenCalledWith('/api/option/', { + key: 'oidc.user_info_endpoint', + value: response.data.userinfo_endpoint, + }) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Save Changes' })).toBeEnabled() + ) + }) + + it.each(['zh', 'zh-TW'])( + 'uses the Well-Known technical term in %s validation', + async (language) => { + await testI18n.changeLanguage(language) + const user = userEvent.setup() + await renderSettings() + await user.click(screen.getByRole('tab', { name: 'OIDC' })) + await user.type( + screen.getByRole('textbox', { name: 'Well-Known URL' }), + 'invalid' + ) + await user.click( + screen.getByRole('button', { name: testI18n.t('Save Changes') }) + ) + + const url = screen.getByRole('textbox', { name: 'Well-Known URL' }) + expect(url).toHaveAccessibleDescription( + expect.stringContaining('Well-Known URL') + ) + expect(url).not.toHaveAccessibleDescription( + expect.stringContaining('知名') + ) + } + ) + + it('keeps edits available for retry when OIDC discovery fails', async () => { + const client = axios.create() + const get = vi + .spyOn(client, 'get') + .mockRejectedValue(new Error('OIDC unavailable')) + vi.spyOn(axios, 'create').mockReturnValue(client) + const errorToast = vi.spyOn(toast, 'error') + const user = userEvent.setup() + await renderSettings() + await user.click(screen.getByRole('tab', { name: 'OIDC' })) + await user.type( + screen.getByRole('textbox', { name: 'Well-Known URL' }), + 'https://provider.example.com/discovery' + ) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + + await waitFor(() => expect(errorToast).toHaveBeenCalledTimes(1)) + expect(api.put).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Save Changes' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled() + expect(screen.getByRole('textbox', { name: 'Well-Known URL' })).toHaveValue( + 'https://provider.example.com/discovery' + ) + get.mockResolvedValue({ data: {} }) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + await waitFor(() => + expect(api.put).toHaveBeenCalledWith('/api/option/', { + key: 'oidc.well_known', + value: 'https://provider.example.com/discovery', + }) + ) + }) +}) diff --git a/web/src/features/system-settings/auth/oauth-section.tsx b/web/src/features/system-settings/auth/oauth-section.tsx index 2f146db30..62f379ad0 100644 --- a/web/src/features/system-settings/auth/oauth-section.tsx +++ b/web/src/features/system-settings/auth/oauth-section.tsx @@ -298,14 +298,23 @@ export function OAuthSection(props: OAuthSectionProps) { const onSubmit = async (values: OAuthFormValues) => { let finalValues = values + const wellKnown = values.oidc.well_known.trim() + const discoveryUrlChanged = + wellKnown !== (baselineRef.current['oidc.well_known'] ?? '').trim() + const enablingOIDC = + values.oidc.enabled && !baselineRef.current['oidc.enabled'] - if (values.oidc.well_known && values.oidc.well_known.trim() !== '') { - const wellKnown = values.oidc.well_known.trim() + // Other providers and disabling OIDC must not depend on OIDC discovery. + if (wellKnown && (discoveryUrlChanged || enablingOIDC)) { if ( !wellKnown.startsWith('http://') && !wellKnown.startsWith('https://') ) { - toast.error(t('Well-Known URL must start with http:// or https://')) + setActiveTab('oidc') + form.setError('oidc.well_known', { + type: 'validate', + message: t('Well-Known URL must start with http:// or https://'), + }) return } @@ -319,6 +328,7 @@ export function OAuthSection(props: OAuthSectionProps) { ...values, oidc: { ...values.oidc, + well_known: wellKnown, authorization_endpoint: authEndpoint, token_endpoint: tokenEndpoint, user_info_endpoint: userInfoEndpoint, @@ -378,7 +388,7 @@ export function OAuthSection(props: OAuthSectionProps) { diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 7176f9d5c..fc621704f 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -6607,8 +6607,8 @@ "Weighted by request count": "Pondéré par le nombre de requêtes", "Welcome back!": "Bienvenue de retour !", "Welcome to our New API...": "Bienvenue sur notre New API...", - "Well-Known URL": "URL bien connue", - "Well-Known URL must start with http:// or https://": "L'URL bien connue doit commencer par http:// ou https://", + "Well-Known URL": "URL Well-Known", + "Well-Known URL must start with http:// or https://": "L’URL Well-Known doit commencer par http:// ou https://", "What would you like to know?": "Que voulez-vous savoir ?", "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Quand un jeton utilise le groupe auto, le système essaie les groupes de haut en bas jusqu’à trouver un groupe disponible.", "When billed as {{group}}": "Facturé sous {{group}}", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 7f9371e91..8b08f1675 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -6607,7 +6607,7 @@ "Weighted by request count": "リクエスト数で加重", "Welcome back!": "おかえりなさい!", "Welcome to our New API...": "New API へようこそ...", - "Well-Known URL": "よく知られたURL", + "Well-Known URL": "Well-Known URL", "Well-Known URL must start with http:// or https://": "Well-Known URL は http:// または https:// で始まる必要があります", "What would you like to know?": "何を知りたいですか?", "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "トークンが auto グループを使用すると、システムは上から順に利用可能なグループを探します。", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 2746de658..c77e3872d 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -6607,7 +6607,7 @@ "Weighted by request count": "Взвешено по количеству запросов", "Welcome back!": "Добро пожаловать обратно!", "Welcome to our New API...": "Добро пожаловать в наш New API...", - "Well-Known URL": "Известный эксперт", + "Well-Known URL": "Well-Known URL", "Well-Known URL must start with http:// or https://": "Well-Known URL должен начинаться с http:// или https://", "What would you like to know?": "Что вы хотели бы узнать?", "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Когда токен использует группу auto, система перебирает группы сверху вниз, пока не найдёт доступную.", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 51404035a..7601e20ae 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -6607,7 +6607,7 @@ "Weighted by request count": "Có trọng số theo số yêu cầu", "Welcome back!": "Chào mừng trở lại!", "Welcome to our New API...": "Chào mừng bạn đến với API mới của chúng tôi...", - "Well-Known URL": "URL đã biết", + "Well-Known URL": "URL Well-Known", "Well-Known URL must start with http:// or https://": "URL Well-Known phải bắt đầu bằng http:// hoặc https://", "What would you like to know?": "Bạn muốn biết gì?", "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Khi token dùng nhóm auto, hệ thống thử các nhóm từ trên xuống dưới cho đến khi tìm được nhóm khả dụng.", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index badf6155a..9230f16eb 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -6608,7 +6608,7 @@ "Welcome back!": "歡迎回來!", "Welcome to our New API...": "歡迎使用我們的 New API...", "Well-Known URL": "Well-Known URL", - "Well-Known URL must start with http:// or https://": "知名 URL 必須以 http:// 或 https:// 開頭", + "Well-Known URL must start with http:// or https://": "Well-Known URL 必須以 http:// 或 https:// 開頭", "What would you like to know?": "您想了解什麼?", "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "當令牌使用 auto 分組時,系統會按從上到下的順序嘗試,直到找到可用分組。", "When billed as {{group}}": "按 {{group}} 收費時", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 4a2e6b507..108e00f2a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -6608,7 +6608,7 @@ "Welcome back!": "欢迎回来!", "Welcome to our New API...": "欢迎使用我们的 New API...", "Well-Known URL": "Well-Known URL", - "Well-Known URL must start with http:// or https://": "知名 URL 必须以 http:// 或 https:// 开头", + "Well-Known URL must start with http:// or https://": "Well-Known URL 必须以 http:// 或 https:// 开头", "What would you like to know?": "您想了解什么?", "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "当令牌使用 auto 分组时,系统会按从上到下的顺序尝试,直到找到可用分组。", "When billed as {{group}}": "按 {{group}} 计费时",