e2e:改用 Keycloak 服务账号 client_credentials 拿 token,4 分钟一换;断言进得了设计端与工作台

This commit is contained in:
xcs
2026-08-25 18:20:44 +08:00
parent fc5afa9600
commit 4dabac2f7d
+28 -12
View File
@@ -25,7 +25,13 @@
*/ */
const BASE = process.env.JCAI_E2E_BASE ?? 'http://localhost:8787'; 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 对行) */ /** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */
const SUITES = { const SUITES = {
@@ -503,12 +509,14 @@ const SUITES = {
}, },
}; };
let cookie = ''; let bearer = '';
let bearerAt = 0;
async function api(path, init = {}) { async function api(path, init = {}) {
if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
...init, ...init,
headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) }, headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) },
}); });
const text = await res.text(); const text = await res.text();
let body; let body;
@@ -526,15 +534,23 @@ async function api(path, init = {}) {
} }
async function login() { 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', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: JSON.stringify({ account: 'AI', password: PASSWORD }), 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()}`); if (!res.ok) throw new Error(`取 token 失败:${res.status} ${await res.text()}`);
const raw = res.headers.get('set-cookie') ?? ''; const { access_token: token } = await res.json();
cookie = raw.split(';')[0] ?? ''; bearer = `Bearer ${token}`;
if (!cookie.includes('jcai_session')) throw new Error(`没拿到会话 cookie:${raw}`); 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) { async function loadSample(activityId, index) {
@@ -731,7 +747,7 @@ async function assertAssetFiles(taskId, spec) {
if (empty) return { ok: false, note: `${key}(${format})是一份空壳 —— 渲得出形状,里头没内容` }; if (empty) return { ok: false, note: `${key}(${format})是一份空壳 —— 渲得出形状,里头没内容` };
// 原字节也得取得到:下载到的是真 .docx / .xlsx // 原字节也得取得到:下载到的是真 .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()); const bytes = new Uint8Array(await raw.arrayBuffer());
if (!raw.ok || bytes.length < 40) return { ok: false, note: `${key} 的原字节取不到(${raw.status})` }; if (!raw.ok || bytes.length < 40) return { ok: false, note: `${key} 的原字节取不到(${raw.status})` };
if ((format === 'docx' || format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) { 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)}` }; return { ok: false, note: `${f.name} 存档里取不回来:${String(error.message).slice(0, 100)}` };
} }
if (got.body.file?.kind === undefined) return { ok: false, note: `${f.name} 取回来是个空壳` }; 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()); 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 (!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)) { if ((f.format === 'docx' || f.format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {