// 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 SkinScore = require('../js/skin-score'); const ReportUI = require('../js/report-ui'); 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 }); let upstreamError = false, openid = 'test-same-wechat-user'; global.fetch = async url => { assert.equal(new URL(url).origin, 'https://api.weixin.qq.com'); return { ok: true, status: 200, json: async () => upstreamError ? { errcode: 40029 } : { openid } }; }; browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP skin.example.test 127.0.0.1'] }); // Fetch in Chromium so its test-host resolver and cookie jar are both used. async function api(page, route, data) { return page.evaluate(async ({ route, data }) => { const response = await fetch('api/' + route, { method: data ? 'POST' : 'GET', headers: data ? { 'Content-Type': 'application/json' } : {}, body: data ? JSON.stringify(data) : undefined }); return { status: response.status, data: await response.json() }; }, { route, data }); } let firstId; const vm = SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: 30 }); const saved = { id: 'RwechatCrossDevice', created_at: new Date().toISOString(), vm, presentation: ReportUI.build(vm, SkinScore.MOCK_RESULT), photos: [null, null, null], payload: { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE } }; async function confirmLogin(page, kind) { await page.waitForSelector('#p-login-success.show'); assert.equal(await page.locator('#login-success-title').textContent(), '登录成功'); assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:游客登录')); assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '游客已登录'); if (process.env.H5_ARTIFACT_DIR) { fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true }); await page.waitForLoadState('networkidle'); await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, kind + '-login-success.png'), animations: 'disabled' }); } assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm'); await page.keyboard.press('Tab'); assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm'); await page.click('[data-action="login-confirm"]'); assert.equal(await page.locator('#p-login-success.show').count(), 0); assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false); assert.equal(await page.locator('#s-splash [data-action="logout-wechat"]').isVisible(), true); } 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('检查')); let browserId, browserToken; if (device === 0) { await page.click('[data-action="guest-experience"]'); await page.waitForSelector('#s-splash.active'); assert.equal(await page.locator('#p-login-success.show').count(), 0); browserId = await page.evaluate(() => AccountClient.user().id); browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1')); assert.equal(browserToken, null); assert.throws(() => accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' })); await page.click('[data-action="history"]'); await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存')); assert.equal(await page.locator('[data-report-id]').count(), 0); await page.click('[data-action="open-wechat-login"]'); assert.equal(await page.locator('#login-consent').isChecked(), false); assert((await page.locator('#login-message').textContent()).includes('后续检测报告')); upstreamError = true; await page.check('#login-consent'); await page.click('[data-action="login-wechat"]'); await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('微信授权未完成')); assert.equal(await page.locator('#s-login.active').count(), 1, 'OAuth failure must not masquerade as browser login success'); assert.equal(await page.locator('#p-login-success.show').count(), 0); await page.waitForFunction(() => !location.search.includes('login_error')); upstreamError = false; } await page.check('#login-consent'); await page.click('[data-action="login-wechat"]'); await page.waitForSelector('#s-splash.active'); await confirmLogin(page, 'wechat'); await page.waitForFunction(() => !location.search.includes('login=success')); const user = await page.evaluate(() => AccountClient.user()); assert.equal(user.kind, 'wechat'); if (firstId) assert.equal(user.id, firstId); else firstId = user.id; if (device === 0) accounts.storeReport(user.id, saved); 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.equal(await page.locator('#s-history [data-auth-status]').textContent(), '微信已登录'); await page.waitForSelector('[data-report-id="RwechatCrossDevice"]'); assert.equal(await page.locator('[data-report-id="RbrowserOriginal"]').count(), 0); assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(JSON.stringify(saved))); if (device === 0) { assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), browserToken); await context.clearCookies(); await page.reload(); await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查')); assert.equal(await page.locator('#s-login.active').count(), 1, 'lost WeChat session must not silently restore the browser account'); assert.equal((await api(page, 'auth/session')).data.user, null); await page.check('#login-consent'); await page.click('[data-action="login-wechat"]'); await page.waitForSelector('#s-splash.active'); await confirmLogin(page, 'wechat'); assert.equal(await page.evaluate(() => AccountClient.user().id), firstId); assert.equal(await page.locator('[data-action="switch-login"]').count(), 0); await context.clearCookies(); await page.goto(base); await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查')); await page.click('[data-action="guest-experience"]'); await page.waitForSelector('#s-splash.active'); assert.equal(await page.locator('#p-login-success.show').count(), 0); assert.notEqual(await page.evaluate(() => AccountClient.user().id), browserId, 'new guest login creates only a fresh temporary session'); await page.click('[data-action="history"]'); await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存')); assert.equal(await page.locator('[data-report-id]').count(), 0); assert.equal(await page.locator('[data-report-id="RwechatCrossDevice"]').count(), 0); assert.equal((await api(page, 'reports/' + saved.id)).status, 401, 'no backend session before welcome consent'); // An OAuth success URL with a lost cookie must not restore an unrelated browser credential. await context.clearCookies(); await page.goto(base + '?login=success'); await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('会话未能保存')); assert.equal((await api(page, 'auth/session')).data.user, null); await page.waitForFunction(() => !location.search.includes('login=success')); await page.reload(); // A guest is never restored from browser storage. await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查')); assert.equal(await page.locator('#s-login.active').count(), 1); assert.equal(await page.evaluate(() => AccountClient.user()), null); assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog'); } if (device === 1) { const reportFile = path.join(root, 'accounts/reports', user.id, saved.id + '.json'); const originalBytes = fs.readFileSync(reportFile, 'utf8'); const logout = page.locator('#s-history [data-action="logout-wechat"]'); assert.equal(await logout.textContent(), '退出登录'); page.once('dialog', dialog => { assert(dialog.message().includes('历史报告将保留')); dialog.dismiss(); }); await logout.click(); assert.equal((await api(page, 'auth/session')).data.user.id, user.id, 'cancel leaves session intact'); await page.route('**/api/auth/logout', route => route.fulfill({ status: 503, json: { ok: false, message: '退出测试失败' } })); page.once('dialog', dialog => dialog.accept()); await logout.click(); await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('退出失败')); assert.equal(await page.evaluate(() => AccountClient.user().id), user.id, 'failure does not fake local logout'); assert.equal((await api(page, 'auth/session')).data.user.id, user.id); await page.unroute('**/api/auth/logout'); await page.waitForFunction(() => !document.querySelector('#s-history [data-action="logout-wechat"]').disabled); let heldReport, markHeld; const reportHeld = new Promise(resolve => { markHeld = resolve; }); await page.route('**/api/reports/' + saved.id, route => { heldReport = route; markHeld(); }, { times: 1 }); await page.click('[data-report-id="RwechatCrossDevice"]'); await reportHeld; page.once('dialog', dialog => dialog.accept()); await logout.click(); await page.waitForSelector('#s-login.active'); assert.equal(await page.evaluate(() => AccountClient.user()), null); assert.equal((await api(page, 'auth/session')).data.user, null); assert.equal((await api(page, 'reports/' + saved.id)).status, 401); assert.equal(await page.locator('#login-consent').isChecked(), false); assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes, 'logout retains exact historical file'); await page.goBack(); await page.waitForSelector('#s-login.active'); await page.click('[data-action="guest-experience"]'); await page.waitForSelector('#s-splash.active'); await page.click('[data-action="history"]'); await page.waitForSelector('#s-history.active'); await heldReport.fulfill({ json: { ok: true, report: JSON.parse(originalBytes) } }); await page.waitForTimeout(250); assert.equal(await page.locator('#s-report.active').count(), 0, 'late WeChat report response cannot open under guest identity'); await page.unroute('**/api/reports/' + saved.id); await page.click('[data-action="open-wechat-login"]'); await page.check('#login-consent'); await page.click('[data-action="login-wechat"]'); await page.waitForSelector('#s-splash.active'); await confirmLogin(page, 'wechat'); assert.equal(await page.evaluate(() => AccountClient.user().id), user.id); await page.click('[data-action="history"]'); await page.waitForSelector('[data-report-id="RwechatCrossDevice"]'); assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(originalBytes)); assert.equal(fs.readFileSync(reportFile, 'utf8'), originalBytes); console.log('PASS WeChat logout: confirm/cancel/failure, server session revoked, back guarded, re-login retains exact report'); } assert.deepEqual(errors, []); await context.close(); } // Different WeChat users must never see each other's reports. openid = 'test-different-wechat-user'; const stranger = await browser.newContext({ ignoreHTTPSErrors: true }); const strangerPage = await stranger.newPage(); await strangerPage.goto(base); const start = await api(strangerPage, 'auth/wechat', { consent: true }); const auth = new URL(start.data.url), callback = new URL(auth.searchParams.get('redirect_uri')); callback.search = new URLSearchParams({ code: 'test-code', state: auth.searchParams.get('state') }); await strangerPage.goto(callback.href); const strangerUser = (await api(strangerPage, 'auth/session')).data.user; assert(strangerUser && strangerUser.id !== firstId); assert.equal((await api(strangerPage, 'reports/' + saved.id)).status, 404); assert.equal((await api(strangerPage, 'reports')).data.total, 0); await stranger.close(); console.log('PASS: HTTPS /state/ simulated WeChat login, Secure cookies, same identity/report across devices, different-user isolation, explicit switching, ephemeral guests without history, OAuth failure/missing-cookie handling, no silent guest fallback'); } 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 }); }