From 1f66824f491a469cc83afaa05640d72e03e9ffcb Mon Sep 17 00:00:00 2001 From: fit2cloud-chenyw Date: Wed, 28 Oct 2026 13:19:27 +0800 Subject: [PATCH] fix: preserve CAS callback compatibility and clarify connection checks --- frontend/src/i18n/en.json | 1 + frontend/src/i18n/ko-KR.json | 1 + frontend/src/i18n/th-TH.json | 1 + frontend/src/i18n/zh-CN.json | 1 + frontend/src/i18n/zh-TW.json | 1 + .../views/system/authentication/CasEditor.vue | 3 +- tests/browser/README.md | 13 +++++ tests/browser/cas.config.mjs | 8 +++ tests/browser/fixtures/cas/bridge.mjs | 9 ++++ tests/browser/fixtures/cas/index.html | 1 + tests/browser/fixtures/cas/main.mjs | 11 ++++ tests/browser/fixtures/cas/server.mjs | 22 ++++++++ tests/browser/specs/cas.spec.mjs | 53 +++++++++++++++++++ 13 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/browser/cas.config.mjs create mode 100644 tests/browser/fixtures/cas/bridge.mjs create mode 100644 tests/browser/fixtures/cas/index.html create mode 100644 tests/browser/fixtures/cas/main.mjs create mode 100644 tests/browser/fixtures/cas/server.mjs create mode 100644 tests/browser/specs/cas.spec.mjs diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index cf7ad40ac..c9ffcf5e3 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -940,6 +940,7 @@ "invalid": "Invalid", "valid": "Valid", "cas_settings": "CAS Settings", + "cas_connection_success": "CAS login endpoint is reachable. Sign in to verify the callback and account.", "callback_domain_name": "Callback Domain", "callback_domain_name_error": "Callback URL must match the current access domain", "field_mapping": "User Attribute Mapping", diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index 394df75f0..e00ef3b87 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -940,6 +940,7 @@ "invalid": "유효하지 않음", "valid": "유효함", "cas_settings": "CAS 설정", + "cas_connection_success": "CAS 로그인 엔드포인트에 연결할 수 있습니다. 실제 로그인으로 콜백과 계정을 확인하세요.", "callback_domain_name": "콜백 도메인", "callback_domain_name_error": "콜백 URL은 현재 접속 도메인과 일치해야 합니다", "field_mapping": "사용자 속성 매핑", diff --git a/frontend/src/i18n/th-TH.json b/frontend/src/i18n/th-TH.json index 8fccdbe85..3743b4c28 100644 --- a/frontend/src/i18n/th-TH.json +++ b/frontend/src/i18n/th-TH.json @@ -940,6 +940,7 @@ "invalid": "ไม่ถูกต้อง", "valid": "ถูกต้อง", "cas_settings": "การตั้งค่า CAS", + "cas_connection_success": "เข้าถึงปลายทางเข้าสู่ระบบ CAS ได้ โปรดเข้าสู่ระบบจริงเพื่อตรวจสอบ callback และบัญชี", "callback_domain_name": "ที่อยู่เรียกกลับ", "callback_domain_name_error": "ที่อยู่เรียกกลับต้องเป็นโดเมนที่ใช้เข้าถึงอยู่ในปัจจุบัน", "field_mapping": "การจับคู่คุณสมบัติผู้ใช้", diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index bf9971eca..8aff119d9 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -940,6 +940,7 @@ "invalid": "无效", "valid": "有效", "cas_settings": "CAS 设置", + "cas_connection_success": "CAS 登录端点可达,请通过实际登录验证回调及账号。", "callback_domain_name": "回调地址", "callback_domain_name_error": "回调地址必须是当前访问域名", "field_mapping": "用户属性映射", diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json index 7ee90759c..41b93e23a 100644 --- a/frontend/src/i18n/zh-TW.json +++ b/frontend/src/i18n/zh-TW.json @@ -940,6 +940,7 @@ "invalid": "無效", "valid": "有效", "cas_settings": "CAS 設定", + "cas_connection_success": "CAS 登入端點可達,請透過實際登入驗證回呼及帳號。", "callback_domain_name": "回調位址", "callback_domain_name_error": "回調位址必須是目前的存取網域名稱", "field_mapping": "使用者屬性對應", diff --git a/frontend/src/views/system/authentication/CasEditor.vue b/frontend/src/views/system/authentication/CasEditor.vue index 584b194a6..2c5ddf708 100644 --- a/frontend/src/views/system/authentication/CasEditor.vue +++ b/frontend/src/views/system/authentication/CasEditor.vue @@ -38,6 +38,7 @@ const validateCbUrl = (rule, value, callback) => { const addr = getSQLBotAddr() if (value === addr || `${value}/` === addr) { callback() + return } callback(new Error(t('authentication.callback_domain_name_error'))) } @@ -187,7 +188,7 @@ const validate = () => { .patch(url, data) .then((res) => { if (res) { - ElMessage.success(t('ds.connection_success')) + ElMessage.success(t('authentication.cas_connection_success')) } else { ElMessage.error(t('ds.connection_failed')) } diff --git a/tests/browser/README.md b/tests/browser/README.md index 341bc1c8b..3da439d96 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -130,3 +130,16 @@ npm --prefix tests/browser test -- --config=welcome-language.config.mjs 不访问业务数据库或 LLM,不替代完整 SDK 握手、真实鉴权和持久化验收。 后端 `tests/test_assistant_welcome_defaults.py` 提取真实 Schema 和保存函数,验证空字符串覆盖、 省略字段保留、会话提交和缓存失效;它同样隔离鉴权与数据库。 + +## CAS 地址与编辑组件回归 + +```sh +npm --prefix tests/browser test -- --config=cas.config.mjs +``` + +复用已安装的 frontend 依赖,独立 Vite 夹具仅监听 `127.0.0.1:4219`,加载真实 +`CasEditor.vue`。HTTP 请求边界替换为内存记录,不访问或修改认证源配置。覆盖根回调 +有无尾斜杠、历史 IdpUri 提交、错误回调拒绝、校验回调仅执行一次及连接检查提示。 +组件库只采纳首次校验结果,因此用原始校验函数的调用计数补充 DOM 提交断言。 +这不验证真实持久化、客户 CAS 服务或最终用户登录;后端地址、编码和票据回归在 +独立 xpack 仓库的 `tests/test_cas_urls.py`。 diff --git a/tests/browser/cas.config.mjs b/tests/browser/cas.config.mjs new file mode 100644 index 000000000..f048fab6f --- /dev/null +++ b/tests/browser/cas.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from '@playwright/test' +import base from './playwright.config.mjs' +export default defineConfig({ + ...base, + webServer: { command: 'node fixtures/cas/server.mjs', url: 'http://127.0.0.1:4219', reuseExistingServer: false, timeout: 30000 }, + projects: [{ name: 'cas', testMatch: 'cas.spec.mjs', use: { baseURL: 'http://127.0.0.1:4219' } }], + outputDir: 'output/cas-results' +}) diff --git a/tests/browser/fixtures/cas/bridge.mjs b/tests/browser/fixtures/cas/bridge.mjs new file mode 100644 index 000000000..3885324c7 --- /dev/null +++ b/tests/browser/fixtures/cas/bridge.mjs @@ -0,0 +1,9 @@ +// Replace only the request boundary; no actual authentication configuration is changed. +export const getSQLBotAddr = () => location.origin + location.pathname +window.savedConfigs = [] +export const request = { + get: async () => null, + patch: async () => true, + post: async (url, data) => { window.savedConfigs.push(data); return 1 }, + put: async (url, data) => { window.savedConfigs.push(data); return 1 }, +} diff --git a/tests/browser/fixtures/cas/index.html b/tests/browser/fixtures/cas/index.html new file mode 100644 index 000000000..6ce9bb4da --- /dev/null +++ b/tests/browser/fixtures/cas/index.html @@ -0,0 +1 @@ +
diff --git a/tests/browser/fixtures/cas/main.mjs b/tests/browser/fixtures/cas/main.mjs new file mode 100644 index 000000000..f34838f5a --- /dev/null +++ b/tests/browser/fixtures/cas/main.mjs @@ -0,0 +1,11 @@ +import { createApp, h, ref } from 'vue' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus-secondary' +import 'element-plus-secondary/dist/index.css' +import Cas from 'cas-form' +import zh from '@/i18n/zh-CN.json' +import en from '@/i18n/en.json' +const form = ref() +createApp({ render: () => h('div', [h(Cas, { ref: form }), + h('button', { onClick: () => form.value.edit() }, 'Open CAS') +]) }).use(ElementPlus).use(createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh, en } })).mount('#app') diff --git a/tests/browser/fixtures/cas/server.mjs b/tests/browser/fixtures/cas/server.mjs new file mode 100644 index 000000000..c3282c1ea --- /dev/null +++ b/tests/browser/fixtures/cas/server.mjs @@ -0,0 +1,22 @@ +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +const root = fileURLToPath(new URL('.', import.meta.url)) +const frontend = path.resolve(root, '../../../../frontend') +const require = createRequire(path.join(frontend, 'package.json')) +const { createServer } = await import(require.resolve('vite')) +const { default: vue } = await import(require.resolve('@vitejs/plugin-vue')) +const bridge = path.join(root, 'bridge.mjs') +const server = await createServer({ + configFile: false, root, cacheDir: '../../output/cas-vite', plugins: [vue()], + resolve: { alias: [ + ...['@/stores/assistant', '@/utils/useCache', '@/utils/utils', '@/utils/request', 'vue-router'].map(find => ({ find, replacement: bridge })), + { find: 'cas-form', replacement: path.join(frontend, 'src/views/system/authentication/CasEditor.vue') }, + { find: '@', replacement: path.join(frontend, 'src') }, + ...['vue', 'vue-i18n', 'element-plus-secondary', 'axios', 'json-bigint'].map(name => ({ find: name, replacement: path.join(frontend, 'node_modules', name) })) + ] }, + define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api/v1') }, + server: { host: '127.0.0.1', port: 4219, strictPort: true, fs: { allow: [root, frontend] } } +}) +await server.listen() +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, async () => { await server.close(); process.exit() }) diff --git a/tests/browser/specs/cas.spec.mjs b/tests/browser/specs/cas.spec.mjs new file mode 100644 index 000000000..9c4389464 --- /dev/null +++ b/tests/browser/specs/cas.spec.mjs @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test' +import fs from 'node:fs' + +test('valid callback invokes validation exactly once', async ({ page }) => { + // The async-validator library settles on the first callback; count all calls explicitly. + const source = fs.readFileSync(new URL('../../../frontend/src/views/system/authentication/CasEditor.vue', import.meta.url), 'utf8') + const body = source.match(/const validateCbUrl = \(rule, value, callback\) => \{([\s\S]*?)\n\}/)[1] + const results = await page.evaluate(body => { + const validate = new Function('rule', 'value', 'callback', 'getSQLBotAddr', 't', body) + return ['https://app.example.invalid', 'https://app.example.invalid/', 'https://wrong.example.invalid/'].map(value => { + const calls = [] + validate(null, value, error => calls.push(error ? 'error' : 'success'), () => 'https://app.example.invalid/', key => key) + return calls + }) + }, body) + expect(results).toEqual([['success'], ['success'], ['error']]) +}) + +for (const suffix of ['', '/']) { + test(`saves historical root callback with suffix ${JSON.stringify(suffix)}`, async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Open CAS' }).click() + const fields = page.getByRole('textbox') + await fields.nth(0).fill('https://cas.example.invalid/cas/login/') + await fields.nth(1).fill(`http://127.0.0.1:4219${suffix}`) + await fields.nth(2).fill('{}') + await page.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => page.evaluate(() => window.savedConfigs.length)).toBe(1) + const config = await page.evaluate(() => JSON.parse(window.savedConfigs[0].config)) + expect(config.casCallbackDomain).toBe(`http://127.0.0.1:4219${suffix}`) + expect(config.idpUri).toBe('https://cas.example.invalid/cas/login/') + }) +} + +test('rejects another callback origin', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Open CAS' }).click() + const fields = page.getByRole('textbox') + await fields.nth(0).fill('https://cas.example.invalid/cas') + await fields.nth(1).fill('https://wrong.example.invalid/') + await fields.nth(2).fill('{}') + await page.getByRole('button', { name: '保存', exact: true }).click() + await expect(page.locator('.ed-form-item__error')).toBeVisible() + expect(await page.evaluate(() => window.savedConfigs)).toEqual([]) +}) + +test('connection success explains its limited scope', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Open CAS' }).click() + await page.getByRole('textbox').nth(0).fill('https://cas.example.invalid/cas') + await page.getByRole('button', { name: '测试连接', exact: true }).click() + await expect(page.getByText('CAS 登录端点可达,请通过实际登录验证回调及账号。')).toBeVisible() +})