verify-board-login-browser.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import assert from 'node:assert/strict';
  4. import { pathToFileURL } from 'node:url';
  5. import { buildModel } from '../src/core/kfs/build.js';
  6. import { seedMemberGroups } from '../src/core/member-groups.js';
  7. import { AUTH_STORAGE_KEY } from '../src/services/platform-auth.js';
  8. const out = path.resolve('docs/board-login-20261111');
  9. await fs.mkdir(out, { recursive: true });
  10. const origin = 'http://127.0.0.1:4181';
  11. const platform = 'https://server.fmode.cn/parse';
  12. const raw = { artifact: [], project: [], source: [], insight: [], opportunity: [], session: [
  13. { objectId: 'login-fixture-session-a', studentName: '测试甲', meta: { participantKey: 'participant:login-fixture-a' } },
  14. { objectId: 'login-fixture-session-b', studentName: '测试乙', meta: { participantKey: 'participant:login-fixture-b' } }
  15. ], asOf: new Date().toISOString() };
  16. const groups = seedMemberGroups(buildModel(raw).members);
  17. const errors = [], checks = [], unexpected = [], liveEvents = [];
  18. const { chromium } = await import(pathToFileURL(path.resolve('work/workshop-manual-tools/node_modules/playwright/index.mjs')).href);
  19. const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' });
  20. const respond = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body),
  21. headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type,X-Parse-Application-Id,X-Parse-Session-Token' } });
  22. async function setup({ live = false, boardFailed = false } = {}) {
  23. const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
  24. const mode = { offlineAuth: false, loggedOut: false };
  25. await context.route('**/*', async route => {
  26. const req = route.request(), url = new URL(req.url());
  27. if (req.url().startsWith(platform + '/')) {
  28. if (live) return route.continue();
  29. if (mode.offlineAuth) return route.abort('failed');
  30. if (req.method() === 'OPTIONS') return respond(route, {});
  31. if (url.pathname === '/parse/login') {
  32. const body = req.postDataJSON();
  33. assert.equal(req.method(), 'POST');
  34. assert.equal(url.search, '');
  35. if (body.password !== 'fixture-browser-only') return respond(route, { code: 101, error: 'fixture invalid credential' }, 404);
  36. mode.loggedOut = false;
  37. return respond(route, { objectId: 'fixture-platform-user', sessionToken: body.username === 'outside' ? 'fixture-outside-token' : 'fixture-valid-token' });
  38. }
  39. if (url.pathname === '/parse/users/me') {
  40. const token = req.headers()['x-parse-session-token'];
  41. if (token === 'fixture-expired-token' || mode.loggedOut) return respond(route, { code: 209, error: 'fixture expired' }, 400);
  42. return respond(route, { objectId: 'fixture-platform-user', username: 'fixture-member', nickname: '登录测试成员',
  43. company: { __type: 'Pointer', className: 'Company', objectId: token === 'fixture-outside-token' ? 'another-company' : 'FKieRbIoGt' },
  44. hash: 'private-fixture-hash', sessionToken: token });
  45. }
  46. if (url.pathname === '/parse/logout') { mode.loggedOut = true;return respond(route, {}); }
  47. unexpected.push(req.method() + ' ' + url.origin + url.pathname);return route.abort();
  48. }
  49. if (url.pathname === '/api/functions' || url.pathname === '/__dev/member-groups') {
  50. assert.ok(!Object.keys(req.headers()).some(key => /session-token|service-token|master-key|authorization/i.test(key)), '平台会话不得发送给业务接口');
  51. const body = req.postDataJSON();
  52. const result = body.path === '/ksfGetPublicBoard' ? raw : body.path === '/ksfGetPublicMemberGroups' ? groups : null;
  53. if (!result) { unexpected.push('Unexpected business action');return route.abort(); }
  54. return respond(route, { result: boardFailed ? { ok: false, errors: [{ message: '隔离夹具:业务数据暂不可用' }] } : { ok: true, data: result } }, boardFailed ? 503 : 200);
  55. }
  56. if (url.origin === origin && req.method() === 'GET') return route.continue();
  57. unexpected.push(req.method() + ' ' + url.origin + url.pathname);return route.abort();
  58. });
  59. const page = await context.newPage();
  60. page.on('pageerror', error => errors.push(error.message));
  61. if (live) page.on('response', response => {
  62. const url = new URL(response.url());
  63. if (response.url().startsWith(platform + '/')) liveEvents.push({ method: response.request().method(), path: url.pathname, status: response.status() });
  64. });
  65. return { context, page, mode };
  66. }
  67. async function submit(page, username = 'fixture-member', password = 'fixture-browser-only') {
  68. await page.locator('view-login input[name="username"]').fill(username);
  69. await page.locator('view-login input[name="password"]').fill(password);
  70. await page.locator('view-login button[type="submit"]').click();
  71. }
  72. let liveVerified = false;
  73. try {
  74. const { context, page, mode } = await setup();
  75. await page.goto(origin + '/workshop?mode=all');
  76. await page.locator('view-workshop-outputs').waitFor();
  77. await page.locator('kf-topbar .auth-link').waitFor();
  78. checks.push('未登录可公开查看成果,不要求先登录');
  79. await page.locator('kf-topbar .auth-link').click();
  80. await page.locator('view-login input[name="username"]').waitFor();
  81. assert.match(page.url(), /\/login\?next=/);
  82. for (const width of [1280, 390]) {
  83. await page.setViewportSize({ width, height: 900 });
  84. const size = await page.evaluate(() => ({ innerWidth, width: document.documentElement.scrollWidth }));
  85. assert.ok(size.width <= size.innerWidth);
  86. await page.screenshot({ path: path.join(out, 'login-' + width + '.png') });
  87. }
  88. checks.push('1280/390px账号密码表单、公开查看链接及无横向溢出');
  89. await submit(page, 'fixture-member', 'fixture-wrong-only');
  90. await page.getByRole('alert').filter({ hasText: '账号或密码不正确' }).waitFor();
  91. assert.equal(await page.locator('view-login input[name="password"]').inputValue(), '');
  92. checks.push('错误密码可恢复,清空密码且不伪报成功');
  93. await submit(page, 'outside');
  94. await page.getByRole('alert').filter({ hasText: '尚未加入康师傅账套' }).waitFor();
  95. assert.equal(await page.locator('kf-topbar .account-name').count(), 0);
  96. checks.push('其他账套账号不能成为康师傅登录身份');
  97. await submit(page);
  98. await page.waitForURL(origin + '/workshop?mode=all');
  99. await page.locator('kf-topbar .account-name').waitFor();
  100. assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
  101. await page.goto(origin + '/');
  102. await page.locator('view-workbench kf-member-picker .member-picker').click();
  103. await page.locator('view-workbench kf-member-picker [data-member="测试乙"]').click();
  104. assert.equal(await page.locator('kf-topbar select').count(), 0);
  105. assert.equal(await page.locator('view-workbench .member-name .name').textContent(), '测试乙');
  106. assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
  107. await page.screenshot({ path: path.join(out, 'logged-in-filter-390.png') });
  108. checks.push('登录返回原页面;切换查看成员保留真实登录账号');
  109. await page.reload();
  110. await page.locator('kf-topbar .account-name').waitFor();
  111. assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
  112. const storageShape = await page.evaluate(key => {
  113. const saved = JSON.parse(sessionStorage.getItem(key));
  114. return { keys: Object.keys(saved), passwordSaved: JSON.stringify(saved).includes('fixture-browser-only'), localPasswordSaved: JSON.stringify(localStorage).includes('fixture-browser-only') };
  115. }, AUTH_STORAGE_KEY);
  116. assert.deepEqual(storageShape, { keys: ['token'], passwordSaved: false, localPasswordSaved: false });
  117. checks.push('刷新重新核验会话;不存密码或完整平台档案');
  118. await page.locator('kf-topbar .logout').click();
  119. await page.locator('kf-topbar .auth-link').waitFor();
  120. assert.equal(await page.evaluate(key => sessionStorage.getItem(key), AUTH_STORAGE_KEY), null);
  121. await page.reload();
  122. await page.locator('kf-topbar .auth-link').waitFor();
  123. checks.push('退出清理会话,刷新保持退出,仍可公开浏览');
  124. await page.evaluate(key => sessionStorage.setItem(key, JSON.stringify({ token: 'fixture-expired-token', user: { displayName: '不能信任的本地姓名' } })), AUTH_STORAGE_KEY);
  125. await page.reload();
  126. await page.locator('kf-topbar .auth-link').waitFor();
  127. assert.equal(await page.evaluate(key => sessionStorage.getItem(key), AUTH_STORAGE_KEY), null);
  128. checks.push('失效/伪造本地会话不显示为已登录,不影响公开看板');
  129. await page.goto(origin + '/login?next=' + encodeURIComponent('https://external.example/'));
  130. await submit(page);
  131. await page.waitForURL(origin + '/');
  132. checks.push('登录返回地址限制在看板内,站外next安全回退');
  133. await page.locator('kf-topbar .logout').click();
  134. await page.locator('kf-topbar .auth-link').waitFor();
  135. await page.goto(origin + '/login');
  136. mode.offlineAuth = true;
  137. await submit(page);
  138. await page.getByRole('alert').filter({ hasText: '暂时无法连接登录服务' }).waitFor();
  139. await page.getByRole('link', { name: '继续公开查看看板 →' }).click();
  140. await page.locator('view-workbench').waitFor();
  141. checks.push('登录服务网络故障有明确提示,公开查看继续可用');
  142. await context.close();
  143. const isolated = await setup({ boardFailed: true });
  144. await isolated.page.goto(origin + '/login');
  145. await isolated.page.locator('view-login input[name="username"]').waitFor();
  146. checks.push('业务取数失败不阻断登录表单');
  147. await isolated.context.close();
  148. if (process.argv.includes('--live')) {
  149. const password = process.env.KSF_ACCOUNT_CHECK_PASSWORD;
  150. if (!password) throw Error('真实核验需通过环境变量提供核验密码');
  151. const live = await setup({ live: true });
  152. try {
  153. await live.page.goto(origin + '/login?next=' + encodeURIComponent('/workshop?mode=all'));
  154. await submit(live.page, 'yeminqian', password);
  155. await live.page.waitForURL(origin + '/workshop?mode=all');
  156. await live.page.locator('kf-topbar .account-name').waitFor();
  157. assert.match(await live.page.locator('kf-topbar .account-name').textContent(), /叶敏倩/);
  158. await live.page.reload();
  159. await live.page.locator('kf-topbar .account-name').waitFor();
  160. assert.match(await live.page.locator('kf-topbar .account-name').textContent(), /叶敏倩/);
  161. await live.page.screenshot({ path: path.join(out, 'live-account-local-board.png') });
  162. await live.page.locator('kf-topbar .logout').click();
  163. await live.page.locator('kf-topbar .auth-link').waitFor();
  164. // The UI clears immediately; wait for the real platform logout response as well.
  165. await live.page.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('kf-toast')?.message?.includes('已退出登录'));
  166. liveVerified = true;
  167. checks.push('真实平台账号在本地看板完成登录→本人显示→刷新核验→服务端退出');
  168. } finally {
  169. // Cleanup only the session created by this browser check, even if an assertion fails.
  170. await live.page.evaluate(async key => {
  171. const saved = JSON.parse(sessionStorage.getItem(key) || 'null');
  172. sessionStorage.removeItem(key);
  173. if (saved?.token) await fetch('https://server.fmode.cn/parse/logout', { method: 'POST', headers: { 'X-Parse-Application-Id': 'ncloudmaster', 'X-Parse-Session-Token': saved.token } }).catch(() => {});
  174. }, AUTH_STORAGE_KEY).catch(() => {});
  175. await live.context.close();
  176. }
  177. }
  178. assert.deepEqual(errors, []);
  179. assert.deepEqual(unexpected, []);
  180. } finally { await browser.close(); }
  181. const result = { ok: true, checks, livePlatformLoginVerified: liveVerified, liveEvents, browserErrors: errors,
  182. unexpectedRequests: unexpected, businessRecordWrites: 0, deployed: false,
  183. fixtureNote: '成果数据为隔离夹具,仅登录的live阶段实际连接平台;不代表真实学员产出。' };
  184. await fs.writeFile(path.join(out, 'browser-verification.json'), JSON.stringify(result, null, 2) + '\n');
  185. console.log(JSON.stringify({ ok: true, checks: checks.length, livePlatformLoginVerified: liveVerified, businessRecordWrites: 0 }));