| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189 |
- import fs from 'node:fs/promises';
- import path from 'node:path';
- import assert from 'node:assert/strict';
- import { pathToFileURL } from 'node:url';
- import { buildModel } from '../src/core/kfs/build.js';
- import { seedMemberGroups } from '../src/core/member-groups.js';
- import { AUTH_STORAGE_KEY } from '../src/services/platform-auth.js';
- const out = path.resolve('docs/board-login-20261111');
- await fs.mkdir(out, { recursive: true });
- const origin = 'http://127.0.0.1:4181';
- const platform = 'https://server.fmode.cn/parse';
- const raw = { artifact: [], project: [], source: [], insight: [], opportunity: [], session: [
- { objectId: 'login-fixture-session-a', studentName: '测试甲', meta: { participantKey: 'participant:login-fixture-a' } },
- { objectId: 'login-fixture-session-b', studentName: '测试乙', meta: { participantKey: 'participant:login-fixture-b' } }
- ], asOf: new Date().toISOString() };
- const groups = seedMemberGroups(buildModel(raw).members);
- const errors = [], checks = [], unexpected = [], liveEvents = [];
- const { chromium } = await import(pathToFileURL(path.resolve('work/workshop-manual-tools/node_modules/playwright/index.mjs')).href);
- const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' });
- const respond = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body),
- 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' } });
- async function setup({ live = false, boardFailed = false } = {}) {
- const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
- const mode = { offlineAuth: false, loggedOut: false };
- await context.route('**/*', async route => {
- const req = route.request(), url = new URL(req.url());
- if (req.url().startsWith(platform + '/')) {
- if (live) return route.continue();
- if (mode.offlineAuth) return route.abort('failed');
- if (req.method() === 'OPTIONS') return respond(route, {});
- if (url.pathname === '/parse/login') {
- const body = req.postDataJSON();
- assert.equal(req.method(), 'POST');
- assert.equal(url.search, '');
- if (body.password !== 'fixture-browser-only') return respond(route, { code: 101, error: 'fixture invalid credential' }, 404);
- mode.loggedOut = false;
- return respond(route, { objectId: 'fixture-platform-user', sessionToken: body.username === 'outside' ? 'fixture-outside-token' : 'fixture-valid-token' });
- }
- if (url.pathname === '/parse/users/me') {
- const token = req.headers()['x-parse-session-token'];
- if (token === 'fixture-expired-token' || mode.loggedOut) return respond(route, { code: 209, error: 'fixture expired' }, 400);
- return respond(route, { objectId: 'fixture-platform-user', username: 'fixture-member', nickname: '登录测试成员',
- company: { __type: 'Pointer', className: 'Company', objectId: token === 'fixture-outside-token' ? 'another-company' : 'FKieRbIoGt' },
- hash: 'private-fixture-hash', sessionToken: token });
- }
- if (url.pathname === '/parse/logout') { mode.loggedOut = true;return respond(route, {}); }
- unexpected.push(req.method() + ' ' + url.origin + url.pathname);return route.abort();
- }
- if (url.pathname === '/api/functions' || url.pathname === '/__dev/member-groups') {
- assert.ok(!Object.keys(req.headers()).some(key => /session-token|service-token|master-key|authorization/i.test(key)), '平台会话不得发送给业务接口');
- const body = req.postDataJSON();
- const result = body.path === '/ksfGetPublicBoard' ? raw : body.path === '/ksfGetPublicMemberGroups' ? groups : null;
- if (!result) { unexpected.push('Unexpected business action');return route.abort(); }
- return respond(route, { result: boardFailed ? { ok: false, errors: [{ message: '隔离夹具:业务数据暂不可用' }] } : { ok: true, data: result } }, boardFailed ? 503 : 200);
- }
- if (url.origin === origin && req.method() === 'GET') return route.continue();
- unexpected.push(req.method() + ' ' + url.origin + url.pathname);return route.abort();
- });
- const page = await context.newPage();
- page.on('pageerror', error => errors.push(error.message));
- if (live) page.on('response', response => {
- const url = new URL(response.url());
- if (response.url().startsWith(platform + '/')) liveEvents.push({ method: response.request().method(), path: url.pathname, status: response.status() });
- });
- return { context, page, mode };
- }
- async function submit(page, username = 'fixture-member', password = 'fixture-browser-only') {
- await page.locator('view-login input[name="username"]').fill(username);
- await page.locator('view-login input[name="password"]').fill(password);
- await page.locator('view-login button[type="submit"]').click();
- }
- let liveVerified = false;
- try {
- const { context, page, mode } = await setup();
- await page.goto(origin + '/workshop?mode=all');
- await page.locator('view-workshop-outputs').waitFor();
- await page.locator('kf-topbar .auth-link').waitFor();
- checks.push('未登录可公开查看成果,不要求先登录');
- await page.locator('kf-topbar .auth-link').click();
- await page.locator('view-login input[name="username"]').waitFor();
- assert.match(page.url(), /\/login\?next=/);
- for (const width of [1280, 390]) {
- await page.setViewportSize({ width, height: 900 });
- const size = await page.evaluate(() => ({ innerWidth, width: document.documentElement.scrollWidth }));
- assert.ok(size.width <= size.innerWidth);
- await page.screenshot({ path: path.join(out, 'login-' + width + '.png') });
- }
- checks.push('1280/390px账号密码表单、公开查看链接及无横向溢出');
- await submit(page, 'fixture-member', 'fixture-wrong-only');
- await page.getByRole('alert').filter({ hasText: '账号或密码不正确' }).waitFor();
- assert.equal(await page.locator('view-login input[name="password"]').inputValue(), '');
- checks.push('错误密码可恢复,清空密码且不伪报成功');
- await submit(page, 'outside');
- await page.getByRole('alert').filter({ hasText: '尚未加入康师傅账套' }).waitFor();
- assert.equal(await page.locator('kf-topbar .account-name').count(), 0);
- checks.push('其他账套账号不能成为康师傅登录身份');
- await submit(page);
- await page.waitForURL(origin + '/workshop?mode=all');
- await page.locator('kf-topbar .account-name').waitFor();
- assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
- await page.goto(origin + '/');
- await page.locator('view-workbench kf-member-picker .member-picker').click();
- await page.locator('view-workbench kf-member-picker [data-member="测试乙"]').click();
- assert.equal(await page.locator('kf-topbar select').count(), 0);
- assert.equal(await page.locator('view-workbench .member-name .name').textContent(), '测试乙');
- assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
- await page.screenshot({ path: path.join(out, 'logged-in-filter-390.png') });
- checks.push('登录返回原页面;切换查看成员保留真实登录账号');
- await page.reload();
- await page.locator('kf-topbar .account-name').waitFor();
- assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
- const storageShape = await page.evaluate(key => {
- const saved = JSON.parse(sessionStorage.getItem(key));
- return { keys: Object.keys(saved), passwordSaved: JSON.stringify(saved).includes('fixture-browser-only'), localPasswordSaved: JSON.stringify(localStorage).includes('fixture-browser-only') };
- }, AUTH_STORAGE_KEY);
- assert.deepEqual(storageShape, { keys: ['token'], passwordSaved: false, localPasswordSaved: false });
- checks.push('刷新重新核验会话;不存密码或完整平台档案');
- await page.locator('kf-topbar .logout').click();
- await page.locator('kf-topbar .auth-link').waitFor();
- assert.equal(await page.evaluate(key => sessionStorage.getItem(key), AUTH_STORAGE_KEY), null);
- await page.reload();
- await page.locator('kf-topbar .auth-link').waitFor();
- checks.push('退出清理会话,刷新保持退出,仍可公开浏览');
- await page.evaluate(key => sessionStorage.setItem(key, JSON.stringify({ token: 'fixture-expired-token', user: { displayName: '不能信任的本地姓名' } })), AUTH_STORAGE_KEY);
- await page.reload();
- await page.locator('kf-topbar .auth-link').waitFor();
- assert.equal(await page.evaluate(key => sessionStorage.getItem(key), AUTH_STORAGE_KEY), null);
- checks.push('失效/伪造本地会话不显示为已登录,不影响公开看板');
- await page.goto(origin + '/login?next=' + encodeURIComponent('https://external.example/'));
- await submit(page);
- await page.waitForURL(origin + '/');
- checks.push('登录返回地址限制在看板内,站外next安全回退');
- await page.locator('kf-topbar .logout').click();
- await page.locator('kf-topbar .auth-link').waitFor();
- await page.goto(origin + '/login');
- mode.offlineAuth = true;
- await submit(page);
- await page.getByRole('alert').filter({ hasText: '暂时无法连接登录服务' }).waitFor();
- await page.getByRole('link', { name: '继续公开查看看板 →' }).click();
- await page.locator('view-workbench').waitFor();
- checks.push('登录服务网络故障有明确提示,公开查看继续可用');
- await context.close();
- const isolated = await setup({ boardFailed: true });
- await isolated.page.goto(origin + '/login');
- await isolated.page.locator('view-login input[name="username"]').waitFor();
- checks.push('业务取数失败不阻断登录表单');
- await isolated.context.close();
- if (process.argv.includes('--live')) {
- const password = process.env.KSF_ACCOUNT_CHECK_PASSWORD;
- if (!password) throw Error('真实核验需通过环境变量提供核验密码');
- const live = await setup({ live: true });
- try {
- await live.page.goto(origin + '/login?next=' + encodeURIComponent('/workshop?mode=all'));
- await submit(live.page, 'yeminqian', password);
- await live.page.waitForURL(origin + '/workshop?mode=all');
- await live.page.locator('kf-topbar .account-name').waitFor();
- assert.match(await live.page.locator('kf-topbar .account-name').textContent(), /叶敏倩/);
- await live.page.reload();
- await live.page.locator('kf-topbar .account-name').waitFor();
- assert.match(await live.page.locator('kf-topbar .account-name').textContent(), /叶敏倩/);
- await live.page.screenshot({ path: path.join(out, 'live-account-local-board.png') });
- await live.page.locator('kf-topbar .logout').click();
- await live.page.locator('kf-topbar .auth-link').waitFor();
- // The UI clears immediately; wait for the real platform logout response as well.
- await live.page.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('kf-toast')?.message?.includes('已退出登录'));
- liveVerified = true;
- checks.push('真实平台账号在本地看板完成登录→本人显示→刷新核验→服务端退出');
- } finally {
- // Cleanup only the session created by this browser check, even if an assertion fails.
- await live.page.evaluate(async key => {
- const saved = JSON.parse(sessionStorage.getItem(key) || 'null');
- sessionStorage.removeItem(key);
- 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(() => {});
- }, AUTH_STORAGE_KEY).catch(() => {});
- await live.context.close();
- }
- }
- assert.deepEqual(errors, []);
- assert.deepEqual(unexpected, []);
- } finally { await browser.close(); }
- const result = { ok: true, checks, livePlatformLoginVerified: liveVerified, liveEvents, browserErrors: errors,
- unexpectedRequests: unexpected, businessRecordWrites: 0, deployed: false,
- fixtureNote: '成果数据为隔离夹具,仅登录的live阶段实际连接平台;不代表真实学员产出。' };
- await fs.writeFile(path.join(out, 'browser-verification.json'), JSON.stringify(result, null, 2) + '\n');
- console.log(JSON.stringify({ ok: true, checks: checks.length, livePlatformLoginVerified: liveVerified, businessRecordWrites: 0 }));
|