From 4dabac2f7dfd23790f63f45ec87dc8810d305c09 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 18:20:44 +0800 Subject: [PATCH] =?UTF-8?q?e2e:=E6=94=B9=E7=94=A8=20Keycloak=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E8=B4=A6=E5=8F=B7=20client=5Fcredentials=20=E6=8B=BF?= =?UTF-8?q?=20token,4=20=E5=88=86=E9=92=9F=E4=B8=80=E6=8D=A2;=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E8=BF=9B=E5=BE=97=E4=BA=86=E8=AE=BE=E8=AE=A1=E7=AB=AF?= =?UTF-8?q?=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/e2e-a2.mjs | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 21d2ce0..1364080 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -25,7 +25,13 @@ */ const BASE = process.env.JCAI_E2E_BASE ?? 'http://localhost:8787'; -const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026'; +/** 身份归统一鉴权中心(ADR-012):e2e 用服务账号的 client_credentials 拿 token,不用人的账号 */ +const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, ''); +const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp'; +const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? ''; +const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? ''; +/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */ +const TOKEN_REFRESH_MS = 4 * 60 * 1000; /** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */ const SUITES = { @@ -503,12 +509,14 @@ const SUITES = { }, }; -let cookie = ''; +let bearer = ''; +let bearerAt = 0; async function api(path, init = {}) { + if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login(); const res = await fetch(`${BASE}${path}`, { ...init, - headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) }, + headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) }, }); const text = await res.text(); let body; @@ -526,15 +534,23 @@ async function api(path, init = {}) { } async function login() { - const res = await fetch(`${BASE}/api/login`, { + if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') { + throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012)'); + } + const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, { method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ account: 'AI', password: PASSWORD }), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'client_credentials', client_id: E2E_CLIENT_ID, client_secret: E2E_CLIENT_SECRET }), }); - if (!res.ok) throw new Error(`登录失败:${res.status} ${await res.text()}`); - const raw = res.headers.get('set-cookie') ?? ''; - cookie = raw.split(';')[0] ?? ''; - if (!cookie.includes('jcai_session')) throw new Error(`没拿到会话 cookie:${raw}`); + if (!res.ok) throw new Error(`取 token 失败:${res.status} ${await res.text()}`); + const { access_token: token } = await res.json(); + bearer = `Bearer ${token}`; + bearerAt = Date.now(); + // 试跑要进设计端(测试台)、答卡要进工作台 —— 服务账号的角色不够,这里就先说清楚 + const me = await api('/api/me'); + for (const end of ['design', 'workbench']) { + if (!me.body.ends.includes(end)) throw new Error(`e2e 服务账号进不了 ${end}:ends=${JSON.stringify(me.body.ends)} —— 给它 realm 角色 admin`); + } } async function loadSample(activityId, index) { @@ -731,7 +747,7 @@ async function assertAssetFiles(taskId, spec) { if (empty) return { ok: false, note: `${key}(${format})是一份空壳 —— 渲得出形状,里头没内容` }; // 原字节也得取得到:下载到的是真 .docx / .xlsx - const raw = await fetch(`${BASE}/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}&raw=1`, { headers: { cookie } }); + const raw = await fetch(`${BASE}/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}&raw=1`, { headers: { authorization: bearer } }); const bytes = new Uint8Array(await raw.arrayBuffer()); if (!raw.ok || bytes.length < 40) return { ok: false, note: `${key} 的原字节取不到(${raw.status})` }; if ((format === 'docx' || format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) { @@ -772,7 +788,7 @@ async function assertArchived(taskId, expected) { return { ok: false, note: `${f.name} 存档里取不回来:${String(error.message).slice(0, 100)}` }; } if (got.body.file?.kind === undefined) return { ok: false, note: `${f.name} 取回来是个空壳` }; - const raw = await fetch(`${BASE}${url}&raw=1`, { headers: { cookie } }); + const raw = await fetch(`${BASE}${url}&raw=1`, { headers: { authorization: bearer } }); const bytes = new Uint8Array(await raw.arrayBuffer()); if (!raw.ok || bytes.length < 40) return { ok: false, note: `${f.name} 的原件取不到(${raw.status} / ${bytes.length}B)` }; if ((f.format === 'docx' || f.format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {