feat(h5): align October UI and add WeChat auth with saved reports
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const create = require('../lib/accounts');
|
||||
const SkinScore = require('../js/skin-score');
|
||||
const ReportUI = require('../js/report-ui');
|
||||
|
||||
(async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-accounts-test-'));
|
||||
let accounts = create(root, {});
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (!await accounts.route(req, res, req.url.split('?')[0])) { res.writeHead(404); res.end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = 'http://127.0.0.1:' + server.address().port;
|
||||
const request = async (url, data, cookie, extra = {}) => {
|
||||
const r = await fetch(base + url, { redirect: 'manual', method: data ? 'POST' : 'GET',
|
||||
headers: { ...(cookie ? { cookie } : {}), ...extra }, body: data ? JSON.stringify(data) : undefined });
|
||||
const text = await r.text();
|
||||
return { status: r.status, data: text ? JSON.parse(text) : null, cookie: r.headers.getSetCookie().map(c => c.split(';')[0]).join('; '), location: r.headers.get('location'), headers: r.headers };
|
||||
};
|
||||
try {
|
||||
assert.equal((await request('/api/reports')).status, 401);
|
||||
assert.equal((await request('/api/auth/browser', {})).status, 400);
|
||||
assert.equal((await request('/api/auth/browser', { consent: true }, '', { Origin: 'https://unrelated.example' })).status, 403);
|
||||
const a = await request('/api/auth/browser', { consent: true });
|
||||
const b = await request('/api/auth/browser', { consent: true });
|
||||
assert.notEqual(a.data.user.id, b.data.user.id);
|
||||
assert(a.headers.get('set-cookie').includes('HttpOnly'));
|
||||
const vm = SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: 30, skinType: '干性肌肤' });
|
||||
const presentation = ReportUI.build(vm, SkinScore.MOCK_RESULT);
|
||||
const snapshot = { id: 'Rtest0', created_at: new Date().toISOString(), vm, presentation,
|
||||
photos: ['data:image/jpeg;base64,front', 'data:image/jpeg;base64,left', 'data:image/jpeg;base64,right'],
|
||||
payload: { result: { ...SkinScore.MOCK_RESULT, face_maps: { brown_area: 'data:image/png;base64,saved-layer' } }, side: SkinScore.MOCK_SIDE } };
|
||||
accounts.storeReport(a.data.user.id, snapshot);
|
||||
assert.deepEqual((await request('/api/reports/Rtest0', null, a.cookie)).data.report, JSON.parse(JSON.stringify(snapshot)));
|
||||
assert.equal((await request('/api/reports/Rtest0', null, b.cookie)).status, 404);
|
||||
assert.equal((await request('/api/reports', null, b.cookie)).data.total, 0);
|
||||
for (let i = 1; i <= 5; i++) accounts.storeReport(a.data.user.id, { ...snapshot, id: 'Rtest' + i });
|
||||
assert.equal((await request('/api/reports', null, a.cookie)).data.total, 5);
|
||||
assert.equal((await request('/api/reports/Rtest0', null, a.cookie)).status, 404);
|
||||
accounts = create(root, {}); // Process restart reloads sessions and reports.
|
||||
assert.equal((await request('/api/reports', null, a.cookie)).data.total, 5);
|
||||
const restored = await request('/api/auth/browser', { consent: true, token: a.data.token });
|
||||
assert.equal(restored.data.user.id, a.data.user.id);
|
||||
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' })).status, 401);
|
||||
await request('/api/auth/logout', {}, restored.cookie);
|
||||
assert.equal((await request('/api/reports', null, restored.cookie)).status, 401);
|
||||
assert.equal((await request('/api/auth/config')).data.wechat, false);
|
||||
accounts = create(root, { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: 'https://skin.example.com/state' });
|
||||
const start = await request('/api/auth/wechat', { consent: true });
|
||||
const url = new URL(start.data.url);
|
||||
assert.equal(url.searchParams.get('redirect_uri'), 'https://skin.example.com/state/api/auth/wechat/callback');
|
||||
assert(start.headers.get('set-cookie').includes('Secure'));
|
||||
assert(start.headers.get('set-cookie').includes('Path=/state/'));
|
||||
const state = url.searchParams.get('state');
|
||||
assert((await request('/api/auth/wechat/callback?code=test&state=' + state)).location.includes('login_error=state'));
|
||||
// Mock only the upstream token exchange, not our HTTP routes, state or persistence.
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = (url, options) => String(url).startsWith('https://api.weixin.qq.com/')
|
||||
? Promise.resolve({ json: async () => ({ openid: 'same-wechat-user' }) }) : realFetch(url, options);
|
||||
try {
|
||||
async function wechat() {
|
||||
const start = await request('/api/auth/wechat', { consent: true });
|
||||
const state = new URL(start.data.url).searchParams.get('state');
|
||||
const callback = await request('/api/auth/wechat/callback?code=test&state=' + state, null, start.cookie);
|
||||
assert.equal(callback.location, '/state/?login=success');
|
||||
assert((await request('/api/auth/wechat/callback?code=test&state=' + state, null, start.cookie)).location.includes('login_error=state'));
|
||||
return (await request('/api/auth/session', null, callback.cookie)).data.user;
|
||||
}
|
||||
assert.deepEqual(await wechat(), await wechat());
|
||||
} finally { global.fetch = realFetch; }
|
||||
accounts = create(root, { TRUST_LOOPBACK_PROXY: '1' });
|
||||
// Malformed credentials exercise the limiter without creating 60 users.
|
||||
for (let i = 0; i < 60; i++) assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.1' })).status, 401);
|
||||
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.1' })).status, 429);
|
||||
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.2' })).status, 401);
|
||||
accounts = create(root, {});
|
||||
for (let i = 0; i < 60; i++) await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.1' });
|
||||
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.2' })).status, 429, 'untrusted forwarding headers cannot bypass rate limit');
|
||||
assert.equal(presentation.views.length, 7);
|
||||
assert(presentation.views.every(v => !/\{[a-z_]+\}/.test(v.text)));
|
||||
assert(!presentation.views.find(v => v.key === 'wrinkles').text.includes('未获取 条'));
|
||||
console.log('PASS: consent, CSRF, isolated owners, exact snapshots, latest 5, restart, restore, logout, OAuth state/replay/subpath/same identity, seven-view copy');
|
||||
} finally {
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
fs.rmSync(root, { recursive: true, force: true }); // Only this test-created temp directory.
|
||||
}
|
||||
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||
@@ -109,13 +109,16 @@ try {
|
||||
return b && b.textContent && b.textContent !== '--';
|
||||
}, null, { timeout: 8000 });
|
||||
const score = await page.locator('#stat-score b').innerText();
|
||||
const essence = await page.locator('#essence-name').innerText();
|
||||
const essence = await page.locator('#product-title').innerText();
|
||||
const reportOn = await page.locator('#s-report.active').count();
|
||||
check('直达报告页', reportOn === 1 && /^\d/.test(score) && essence && essence !== '--',
|
||||
`score=${score} essence=${essence}`);
|
||||
|
||||
// 3) 完整 mock 流程:启动 → 档案 → 三连拍 → 报告,并记录语音起播
|
||||
await page.goto(BASE + '/?mock=1', { waitUntil: 'networkidle' });
|
||||
await page.locator('#login-consent').check();
|
||||
await page.locator('[data-action="login-browser"]').click();
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.getByRole('button', { name: '开始检测' }).click();
|
||||
await page.locator('#privacy-check').click();
|
||||
await page.getByRole('button', { name: '继续' }).click();
|
||||
@@ -138,11 +141,11 @@ try {
|
||||
return v && v.videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live');
|
||||
}, null, { timeout: 12000 });
|
||||
const step1 = (await page.locator('#cam-step').innerText()).replace(/\s+/g, '');
|
||||
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正对屏幕'), step1);
|
||||
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正脸出镜'), step1);
|
||||
|
||||
// mock 无人脸检测,快门作为调试入口保留;听完每步引导再按(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
|
||||
// 步骤顺序与轮廓对齐 Figma:正脸 → 右脸(耳在画面右,mask 2)→ 左脸(耳在画面左,mask 1)
|
||||
const FIGMA_STEPS = [['正对屏幕', '0'], ['右脸', '2'], ['左脸', '1']];
|
||||
const FIGMA_STEPS = [['正脸出镜', '0'], ['右脸', '2'], ['左脸', '1']];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const n = i + 1;
|
||||
await page.waitForFunction((num) => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Isolated HTTP + browser integration. Upstream Face++ is fixture-backed; no paid call.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import http from 'node:http';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const h5 = fileURLToPath(new URL('../', import.meta.url));
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-report-test-'));
|
||||
const fx = {};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
|
||||
const fixture = fx.MOCK_FIXTURE;
|
||||
const child = spawn(process.execPath, ['-e', `
|
||||
const fs=require('fs'), vm=require('vm'), w={};
|
||||
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
|
||||
const f=w.MOCK_FIXTURE;
|
||||
global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})});
|
||||
require('./server.js');
|
||||
`], { cwd: h5, env: { ...process.env, PORT: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
||||
SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let browser;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('test server startup timeout')), 10000);
|
||||
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } });
|
||||
child.once('exit', code => { clearTimeout(timeout); reject(new Error('server exited: ' + code)); });
|
||||
});
|
||||
const base = 'http://127.0.0.1:18089/';
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(base);
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
const api = page.context().request;
|
||||
const result = await (await api.post(base + 'api/skin-analyze', { data: { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile: { real_age: 28, daily_skin_type: '干性肌肤' } } })).json();
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.report_saved, true);
|
||||
assert(result.vm && result.presentation);
|
||||
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
|
||||
assert.equal(snapshot.report.photos.length, 3);
|
||||
assert.deepEqual(snapshot.report.presentation, result.presentation);
|
||||
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
|
||||
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
|
||||
// A separate browser cannot access this user's report, even knowing its ID.
|
||||
const other = await browser.newContext();
|
||||
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401);
|
||||
await other.request.post(base + 'api/auth/browser', { data: { consent: true } });
|
||||
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 404);
|
||||
for (const route of ['data/accounts/identities.json', 'lib/accounts.js', 'server.js', 'test/accounts.js', '%2edata/identities.json']) {
|
||||
assert([403,404].includes((await api.get(base + route)).status()), route);
|
||||
}
|
||||
// Raw HTTP avoids URL clients normalizing away the malicious path under test.
|
||||
for (const route of ['//data/accounts/identities.json', '/%2fdata/accounts/identities.json',
|
||||
'/%5cdata/accounts/identities.json', '//lib/accounts.js', '/%2fserver.js', '/assets/../data/accounts/identities.json']) {
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
http.get({ hostname: '127.0.0.1', port: 18089, path: route }, r => { r.resume(); resolve(r.statusCode); }).on('error', reject);
|
||||
});
|
||||
assert([403,404].includes(status), route);
|
||||
}
|
||||
await page.click('[data-action="history"]');
|
||||
await page.click('[data-report-id]');
|
||||
await page.waitForSelector('#s-report.active');
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
|
||||
const imagesBefore = await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src));
|
||||
const copyBefore = await page.locator('.ditem-para').allTextContents();
|
||||
await page.reload();
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="history"]');
|
||||
await page.click('[data-report-id]');
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
|
||||
assert.deepEqual(await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src)), imagesBefore);
|
||||
assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copyBefore);
|
||||
await page.goBack();
|
||||
await page.waitForSelector('#s-history.active');
|
||||
await page.goBack();
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: authenticated analyze → full snapshot → backend history → reload → seven identical images/copy; owner isolation, private files, browser back');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
child.kill('SIGTERM');
|
||||
if (child.exitCode === null) await once(child, 'exit');
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
const root = new URL('../../design/ui-20261009/verification/', import.meta.url);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
|
||||
await page.goto(base);
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: new URL('01-login.png', root).pathname });
|
||||
await page.click('[data-action="login-browser"]');
|
||||
assert(await page.locator('#s-login').evaluate(e => e.classList.contains('active')));
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('本机浏览器'));
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.waitForFunction(() => !document.querySelector('#toast').classList.contains('show'));
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: new URL('02-home.png', root).pathname });
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: new URL('03-history-empty.png', root).pathname });
|
||||
await page.goBack();
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.reload();
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.click('[data-action="open-privacy"]');
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: new URL('04-privacy.png', root).pathname });
|
||||
await page.click('#privacy-check');
|
||||
await page.click('[data-action="agree-continue"]');
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal(await page.locator('#s-profile').evaluate(e => e.getBoundingClientRect().top), 0);
|
||||
await page.screenshot({ path: new URL('05-profile.png', root).pathname });
|
||||
// Static mask layout audit only; real capture flow is covered by e2e-browser.mjs.
|
||||
await page.evaluate(() => document.querySelectorAll('.screen').forEach(s => s.classList.toggle('active', s.id === 's-cam')));
|
||||
await page.waitForTimeout(400);
|
||||
for (const [step, name, label] of [['0', 'front', '1/3<br>请正脸出镜'], ['2', 'right', '2/3<br>请右脸出镜'], ['1', 'left', '3/3<br>请左脸出镜']]) {
|
||||
await page.evaluate(({ step, label }) => { document.querySelector('#cam-mask').dataset.step = step; document.querySelector('#cam-step').innerHTML = label; }, { step, label });
|
||||
await page.screenshot({ path: new URL('08-camera-' + name + '-layout.png', root).pathname });
|
||||
}
|
||||
await page.goto(base + '?mock=1&screen=report');
|
||||
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--');
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await page.locator('.card-detail').count(), 3);
|
||||
assert.equal(await page.locator('.dtab').count(), 7);
|
||||
assert.equal(await page.locator('.card-product img#product-image').count(), 1);
|
||||
await page.screenshot({ path: new URL('06-report-top.png', root).pathname });
|
||||
await page.locator('#s-report').evaluate(e => { e.style.position = 'relative'; e.style.height = 'auto'; e.style.overflow = 'visible'; document.body.style.overflow = 'visible'; });
|
||||
await page.screenshot({ path: new URL('07-report-full.png', root).pathname, fullPage: true });
|
||||
for (const card of await page.locator('.card-detail').all()) {
|
||||
for (const tab of await card.locator('.dtab').all()) {
|
||||
await tab.click();
|
||||
assert.equal(await card.locator('.dpanel.active').count(), 1);
|
||||
}
|
||||
}
|
||||
for (const width of [320, 390, 480]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'horizontal overflow ' + width);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: login/consent/fallback/refresh/history/profile/report/7 tabs/3 viewport widths, no JS errors');
|
||||
await browser.close();
|
||||
@@ -0,0 +1,80 @@
|
||||
// Real HTTPS cookies + /state/ reverse-proxy layout; only WeChat is simulated.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import https from 'node:https';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
const require = createRequire(import.meta.url);
|
||||
const createAccounts = require('../lib/accounts');
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-wechat-test-'));
|
||||
const h5 = fileURLToPath(new URL('../', import.meta.url));
|
||||
let browser, server;
|
||||
const realFetch = global.fetch;
|
||||
try {
|
||||
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(root, 'key.pem'),
|
||||
'-out', path.join(root, 'cert.pem'), '-days', '1', '-subj', '/CN=skin.example.test'], { stdio: 'ignore' });
|
||||
let accounts;
|
||||
server = https.createServer({ key: fs.readFileSync(path.join(root, 'key.pem')), cert: fs.readFileSync(path.join(root, 'cert.pem')) }, async (req, res) => {
|
||||
const url = new URL(req.url, 'https://skin.example.test');
|
||||
if (!url.pathname.startsWith('/state/')) { res.writeHead(404); return res.end(); }
|
||||
req.url = req.url.slice('/state'.length); // nginx proxy_pass trailing slash
|
||||
const route = url.pathname.slice('/state'.length);
|
||||
if (await accounts.route(req, res, route)) return;
|
||||
const file = route === '/' ? '/index.html' : route;
|
||||
if (!/^\/(index\.html|(?:js|css|assets|vendor)\/[^.][^]*)$/.test(file) || file.includes('..')) { res.writeHead(404); return res.end(); }
|
||||
try {
|
||||
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' };
|
||||
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream');
|
||||
res.end(fs.readFileSync(path.join(h5, file)));
|
||||
} catch (_) { res.writeHead(404); res.end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = 'https://skin.example.test:' + server.address().port + '/state/';
|
||||
accounts = createAccounts(path.join(root, 'accounts'), { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: base });
|
||||
global.fetch = async url => {
|
||||
assert.equal(new URL(url).origin, 'https://api.weixin.qq.com');
|
||||
return { ok: true, json: async () => ({ openid: 'test-same-wechat-user' }) };
|
||||
};
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP skin.example.test 127.0.0.1'] });
|
||||
let firstId;
|
||||
for (let device = 0; device < 2; device++) {
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.route('https://open.weixin.qq.com/**', async route => {
|
||||
const auth = new URL(route.request().url());
|
||||
assert.equal(auth.searchParams.get('scope'), 'snsapi_base');
|
||||
const callback = new URL(auth.searchParams.get('redirect_uri'));
|
||||
assert.equal(callback.href, base + 'api/auth/wechat/callback');
|
||||
callback.search = new URLSearchParams({ code: 'test-code', state: auth.searchParams.get('state') });
|
||||
await route.fulfill({ status: 302, headers: { location: callback.href }, body: '' });
|
||||
});
|
||||
await page.goto(base);
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
// Redirects may preserve WeChat's fragment; it is not sent to the backend.
|
||||
await page.waitForURL(url => url.origin + url.pathname === base && url.searchParams.get('login') === 'success');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
const user = await page.evaluate(() => AccountClient.user());
|
||||
assert.equal(user.kind, 'wechat');
|
||||
if (firstId) assert.equal(user.id, firstId); else firstId = user.id;
|
||||
const cookie = (await context.cookies()).find(c => c.name === 'juzi_session');
|
||||
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
console.log('PASS: HTTPS /state/ login → simulated WeChat → callback with bound Secure cookie → homepage/history; two browser contexts share WeChat identity');
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
if (browser) await browser.close();
|
||||
if (server) await new Promise(resolve => server.close(resolve));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user