feat(h5): allow guest access without login and retain history only for WeChat

This commit is contained in:
xsl
2026-10-13 15:23:23 +08:00
parent 98935dd168
commit 1fdbb575b0
29 changed files with 2561 additions and 1866 deletions
+13 -6
View File
@@ -26,8 +26,16 @@ const ReportUI = require('../js/report-ui');
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 });
const guest = await request('/api/auth/browser', { consent: true });
assert.equal(guest.data.token, undefined, 'guest credentials are never persisted by clients');
assert(!guest.headers.get('set-cookie').includes('Max-Age='), 'guest cookie is session-only');
assert.equal(fs.existsSync(path.join(root, 'identities.json')), false, 'guest login does not write an identity');
assert.throws(() => accounts.storeReport(guest.data.user.id, { id: 'Rguest' }));
assert.equal((await request('/api/reports', null, guest.cookie)).data.total, 0);
accounts = create(root, {});
assert.equal((await request('/api/auth/session', null, guest.cookie)).data.user, null, 'process restart forgets guests');
const [a, b] = require('./wechat-fixture.cjs')(root, 2).map(entry => ({ ...entry, data: { user: entry.user }, headers: guest.headers }));
accounts = create(root, {});
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: '干性肌肤' });
@@ -48,11 +56,10 @@ const ReportUI = require('../js/report-ui');
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/session', null, a.cookie)).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);
await request('/api/auth/logout', {}, a.cookie);
assert.equal((await request('/api/reports', null, a.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 });
+1 -2
View File
@@ -40,9 +40,8 @@ try {
await page.evaluate(() => { window.faceapi = undefined; });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
+8 -6
View File
@@ -13,6 +13,7 @@ const ReportUI = require('../js/report-ui');
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-fourth-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const artifacts = process.env.H5_ARTIFACT_DIR;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'), vm=require('vm'), w={};
@@ -45,13 +46,14 @@ try {
await page.goto(base); await page.waitForLoadState('networkidle');
assert.equal(await page.title(), "1'me肌肤状态检测");
assert.equal(await page.locator('meta[property="og:title"]').getAttribute('content'), "1'me肌肤状态检测");
assert.equal((await page.locator('[data-action="login-browser"]').textContent()).trim(), '游客登录');
assert.equal((await page.locator('[data-action="guest-experience"]').textContent()).trim(), '免登录体验');
assert(!requests.some(p => /scan-(base|face)|\.woff2$|faceapi/.test(p)), 'no heavy/scanning entry requests');
await page.check('#login-consent'); await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
assert((await page.locator('#login-success-message').textContent()).includes('当前使用:游客登录'));
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客已登录');
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.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
await page.evaluate(() => AccountClient.init()); // Persistence scenarios require WeChat.
const api = page.context().request;
await page.addScriptTag({ url: base + 'assets/mock/report-fixture.js' });
const fixture = await page.evaluate(() => window.MOCK_FIXTURE);
+1 -2
View File
@@ -117,9 +117,8 @@ try {
// 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.locator('[data-action="guest-experience"]').click();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
+138
View File
@@ -0,0 +1,138 @@
// Isolated backend and fixture-backed analysis. No production data or paid API.
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 { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.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-guest-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
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; let calls=0;
global.fetch=async()=>({ok:true,status:200,json:async()=>++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
{request_id:'fixture',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: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
function diskSnapshot(dir = dataRoot, prefix = '') {
const files = {};
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const name = prefix + entry.name;
if (entry.isDirectory()) Object.assign(files, diskSnapshot(path.join(dir, entry.name), name + '/'));
else files[name] = fs.readFileSync(path.join(dir, entry.name)).toString('base64');
}
return files;
}
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); });
});
const base = 'http://127.0.0.1:18115/';
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = []; page.on('pageerror', e => errors.push(e.message));
const guestRequests = [];
page.on('request', r => { if (r.url().endsWith('/api/auth/browser')) guestRequests.push(r.url()); });
await page.addInitScript(() => {
localStorage.setItem('juzi.identity.v1', 'old-guest-token');
localStorage.setItem('juzi.reports.v1', '[{"id":"old-report"}]');
localStorage.setItem('juzi.profile.v1.browser.old', JSON.stringify({ gender: '男' }));
});
const baseline = diskSnapshot();
await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'old guest token cannot silently restore login');
const artifacts = process.env.H5_ARTIFACT_DIR;
async function screenshot(name) {
await page.evaluate(async () => { await document.fonts.ready; window.scrollTo(0, 0); });
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(artifacts, name + '.png'), animations: 'disabled' });
}
if (artifacts) { fs.mkdirSync(artifacts, { recursive: true }); await screenshot('guest-entry'); }
assert.equal(await page.locator('#login-consent').isChecked(), false);
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'guest entry never shows login success');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0, 'switch-login controls are removed');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
assert.equal(guestRequests.length, 0, 'guest entry performs no authentication request');
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), null);
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.reports.v1')), null);
assert.equal(await page.evaluate(() => Object.keys(localStorage).some(k => k.startsWith('juzi.profile.v1.browser.'))), false);
assert.equal((await page.context().cookies()).some(c => c.name === 'juzi_session'), false);
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await screenshot('guest-home');
}
await page.click('[data-action="open-privacy"]'); await page.waitForSelector('#p-privacy.show');
assert.equal(await page.locator('#privacy-policy-link').textContent(), '隐私协议');
const linkStyle = await page.locator('#privacy-policy-link').evaluate(el => ({ weight: getComputedStyle(el).fontWeight, decoration: getComputedStyle(el).textDecorationLine }));
assert(Number(linkStyle.weight) >= 600 && linkStyle.decoration.includes('underline'));
assert.equal(await page.locator('.privacy-text').evaluate(el => getComputedStyle(el).textDecorationLine), 'none');
if (artifacts) await screenshot('welcome-privacy');
await page.click('#privacy-policy-link'); await page.waitForSelector('#p-policy.show');
assert.equal(await page.locator('#privacy-check').evaluate(el => el.classList.contains('checked')), false, 'opening agreement does not tick consent');
await page.click('[data-action="close-policy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(guestRequests.length, 0, 'no temporary server session before consent');
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert.equal(guestRequests.length, 1, 'temporary access is created only after welcome consent');
const cookie = (await page.context().cookies()).find(c => c.name === 'juzi_session');
assert.equal(cookie.expires, -1, 'temporary cookie is non-persistent');
await page.reload(); await page.waitForSelector('#s-splash.active');
const api = page.context().request;
const data = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0],
profile: { real_age: 30, gender: '女', region: '北京市', daily_skin_type: '干性肌肤' } };
const guestResponse = await api.post(base + 'api/skin-analyze', { data });
assert.equal(guestResponse.headers()['cache-control'], 'no-store');
const guest = await guestResponse.json();
assert.equal(guest.ok, true); assert.equal(guest.report_saved, false); assert(guest.vm && guest.presentation);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 0);
assert.equal((await api.get(base + 'api/reports/' + guest.record_id)).status(), 404);
const failed = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(failed.ok, false);
await page.waitForTimeout(150); assert.deepEqual(diskSnapshot(), baseline, 'guest success/failure writes no identity, report, photo, profile, raw result or admin record');
await page.click('[data-action="history"]');
assert((await page.locator('#identity-note').textContent()).includes('不保存个人信息和检测记录'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
if (artifacts) await screenshot('guest-history');
// Same backend and payload: WeChat must continue to retain complete reports.
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
const saved = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(saved.ok, true); assert.equal(saved.report_saved, true);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 1);
assert.equal((await (await api.get(base + 'api/reports/' + saved.record_id)).json()).report.id, saved.record_id);
assert(fs.existsSync(path.join(dataRoot, 'records.jsonl')));
assert(fs.existsSync(path.join(dataRoot, 'photos', saved.record_id, 'front.jpg')));
// Visual sample only; not another analysis call.
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#care-list').children.length > 1);
await page.evaluate(() => document.fonts.ready);
await page.locator('.card-care').scrollIntoViewIfNeeded();
const spacing = await page.locator('.card-care').evaluate(el => {
const heading = el.querySelector('.card-heading'), body = el.querySelector('#care-list');
return { margin: parseFloat(getComputedStyle(heading).marginBottom), gap: body.getBoundingClientRect().top - heading.getBoundingClientRect().bottom };
});
assert(Math.abs(spacing.margin - 9) < .1 && Math.abs(spacing.gap - 9) < 1, 'care title gap is halved from 18px to 9px');
if (artifacts) await page.locator('.card-care').screenshot({ path: path.join(artifacts, 'care-spacing.png') });
assert.deepEqual(errors, []);
console.log('PASS: four-character bold/underlined privacy link, half care gap; guest success/failure no disk writes or saved history/identity; WeChat retains snapshots/photos/admin records; screenshots; no paid calls');
} finally {
if (browser) await browser.close();
if (child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+8 -7
View File
@@ -72,13 +72,13 @@ try {
assert(!requests.some(p => /faceapi|models|\.woff2$|bg-splash\.jpg$/.test(p)), JSON.stringify(requests));
const resourceBytes = await page.evaluate(() => performance.getEntriesByType('resource').reduce((sum, r) => sum + r.encodedBodySize, 0));
assert(resourceBytes < 350000, 'login resource budget exceeded: ' + resourceBytes);
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.waitForTimeout(400); // Allow the home entry animation to settle.
const artifacts = process.env.H5_ARTIFACT_DIR;
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, 'browser-login-success.png') });
await page.screenshot({ path: path.join(artifacts, 'guest-no-login.png') });
}
await page.keyboard.press('Escape');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
@@ -94,10 +94,11 @@ try {
assert(!requests.some(p => /faceapi|models|\.woff2$/.test(p)), JSON.stringify(requests));
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-home-status.png') });
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '游客已登录');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '游客模式');
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-history-status.png') });
await page.click('#s-history [data-action="switch-login"]');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0);
await page.click('#s-history [data-action="open-wechat-login"]');
assert.equal(await page.locator('#s-login.active').count(), 1);
assert.deepEqual(errors, []);
console.log('PASS: gzip/304/revalidation, no-store private API, login without fonts/face engine, success dialog/Escape/status/switching/layout; throttled cold login=' + readyMs + 'ms, resources=' + resourceBytes + ' bytes');
+20 -32
View File
@@ -35,22 +35,23 @@ try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
const api = context.request;
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
assert(cookie && cookie.secure && cookie.sameSite === 'Lax' && cookie.path === new URL(base).pathname);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {} })).status(), 400);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {}, headers: { Origin: 'https://invalid.example' } })).status(), 403);
console.log('PASS: public HTTPS, secure scoped cookie, missing-image validation, cross-origin denial');
assert.equal((await api.get(base + 'api/reports')).status(), 401);
await page.click('[data-action="open-privacy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(await page.locator('#s-profile.active').count(), 0);
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
assert(cookie && cookie.secure && cookie.sameSite === 'Lax' && cookie.path === new URL(base).pathname);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {} })).status(), 400);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {}, headers: { Origin: 'https://invalid.example' } })).status(), 403);
console.log('PASS: consent-gated temporary HTTPS session, secure scoped cookie, missing-image validation, cross-origin denial');
await page.click('[data-action="submit-profile"]');
assert.equal(await page.locator('#s-profile.active').count(), 1);
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
@@ -79,17 +80,12 @@ try {
assert.equal(response.status(), 200);
const result = await response.json();
assert.equal(result.ok, true, JSON.stringify({ code: result.code, message: result.message }));
assert.equal(result.report_saved, true);
assert.equal(result.report_saved, false, 'guest reports are deliberately not saved');
assert.equal(analyzeCalls, 1);
assert((await page.evaluate(() => window.__analyzeTimeouts)).includes(150000));
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
const snap = (await (await api.get(base + 'api/reports/' + result.record_id)).json()).report;
assert.equal(snap.photos.filter(Boolean).length, 3);
assert.deepEqual(snap.presentation, result.presentation);
assert.deepEqual(snap.payload.result.face_maps, result.result.face_maps);
const images = await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src));
const copy = await page.locator('.ditem-para').allTextContents();
assert.equal((await api.get(base + 'api/reports/' + result.record_id)).status(), 404);
for (const card of await page.locator('.card-detail').all()) {
for (const tab of await card.locator('.dtab').all()) {
await tab.click();
@@ -102,31 +98,23 @@ try {
await page.waitForSelector('#p-zoom.show', { state: 'hidden' });
assert.equal(await page.locator('#s-report.active').count(), 1);
await page.screenshot({ path: path.join(artifacts, 'real-report.png'), fullPage: true });
console.log('PASS: three sample views → REAL Face++ → scoring → seven images/tabs → zoom/back → backend snapshot');
console.log('PASS: three sample views → REAL Face++ → scoring → seven images/tabs → zoom/back; guest report is not saved');
// Cookie loss is restored using this browser's stored random identity, not a new account.
// Cookie loss cannot restore a guest identity or any guest history.
await context.clearCookies();
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#s-login.active');
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id="' + result.record_id + '"]');
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
assert.deepEqual(await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src)), images);
assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copy);
await page.goBack();
await page.waitForSelector('#s-history.active');
// Read failure + explicit retry must recover without losing the report.
await page.route('**/api/reports', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ ok: false, message: '测试临时不可用' }) }));
await page.click('[data-action="history-retry"]');
await page.waitForFunction(() => document.querySelector('#history-list').textContent.includes('测试临时不可用'));
await page.unroute('**/api/reports');
await page.click('[data-action="history-retry"]');
await page.waitForSelector('[data-report-id="' + result.record_id + '"]');
assert.equal(await page.locator('[data-report-id]').count(), 0);
assert((await page.locator('#identity-note').textContent()).includes('不保存'));
await page.screenshot({ path: path.join(artifacts, 'real-history.png') });
await page.goBack();
await page.waitForSelector('#s-splash.active');
console.log('PASS: cookie restoration, report reload with identical seven images/copy, history error/retry, browser back');
console.log('PASS: guest has no credential restoration or saved history; refresh/back navigation works');
const other = await browser.newContext();
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401);
@@ -139,7 +127,7 @@ try {
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(artifacts, 'summary.json'), JSON.stringify({ record_id: result.record_id, analyzeCalls, errors, tested_at: new Date().toISOString() }, null, 2));
console.log('PASS: cross-account isolation, admin/source/data protection, zero JS errors');
console.log('Test report (dedicated identity): ' + result.record_id + '; artifacts: ' + artifacts);
console.log('Temporary guest verification report (not saved): ' + result.record_id + '; artifacts: ' + artifacts);
} finally {
await browser.close();
}
+6 -2
View File
@@ -27,7 +27,7 @@ try {
const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true,
userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' });
const page = await context.newPage(), errors = [], blockedWrites = [];
let user = { id: 'profile-fixture-a', kind: 'browser' };
let user = { id: 'profile-fixture-a', kind: 'wechat' };
page.setDefaultTimeout(20000);
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => {
@@ -88,7 +88,7 @@ try {
user = { id: 'profile-fixture-b', kind: 'wechat' };
await enter(); assert.deepEqual(await values(), blank, 'another account does not inherit profile/picker/skin values');
await pick('#pick-gender'); assert.equal((await values()).gender, '女', 'new account wheel reset too');
user = { id: 'profile-fixture-a', kind: 'browser' };
user = { id: 'profile-fixture-a', kind: 'wechat' };
await page.evaluate(() => AccountClient.init());
await page.goBack(); await page.waitForSelector('#s-splash.active');
await enter(false); assert.deepEqual(await values(), original, 'switching back without a reload restores the right account');
@@ -182,6 +182,10 @@ try {
await enter(); assert.equal((await values()).skin, 3, 'skin selection also survives reload');
await page.click('[data-action="submit-profile"]');
await page.waitForSelector('#p-photoguide.show');
user = { id: 'profile-fixture-guest', kind: 'browser' };
await enter(); assert.deepEqual(await values(), blank, 'guests never inherit WeChat preferences');
await pick('#pick-gender', [1]); assert.equal((await values()).gender, '男');
await enter(); assert.deepEqual(await values(), blank, 'guest preferences do not survive reload');
assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, [], 'no backend writes or paid requests');
console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile finger-follow/snap/flick/edges/cancel; fixed profile/no vertical movement/controls fit in seven viewport sizes; real birth-wheel vertical touch still works; arrows/dots/keyboard/submission; no JS errors/backend writes');
} finally {
+4 -2
View File
@@ -2,7 +2,7 @@ const assert = require('node:assert/strict');
const Profile = require('../js/profile-store');
const regions = [['北京市', ['北京市']], ['浙江省', ['杭州市', '宁波市']]];
const now = new Date(2026, 9, 12);
const user = { id: 'fixture-a', kind: 'browser' }, other = { id: 'fixture-b', kind: 'browser' };
const user = { id: 'fixture-a', kind: 'wechat' }, other = { id: 'fixture-b', kind: 'wechat' };
const valid = { birth: '1991-11', gender: '男', region: { province: '浙江省', city: '宁波市' }, skinIndex: 3 };
const empty = { birth: '', gender: '', region: null, skinIndex: 0 };
assert.equal(Profile.age(valid.birth, now), 34);
@@ -30,7 +30,9 @@ assert.equal(store.read(user), null);
store.write(user, valid);
assert.deepEqual(Profile.create(() => storage).read(user), valid, 'survives a page reload');
assert.equal(store.read(other), null, 'different accounts are isolated');
assert.equal(store.read({ ...user, kind: 'wechat' }), null, 'login kinds are isolated');
assert.equal(store.read({ ...user, kind: 'browser' }), null, 'guests cannot read saved preferences');
store.write({ ...user, kind: 'browser' }, valid);
assert.equal(Profile.key({ ...user, kind: 'browser' }), null);
store.write(other, empty); assert.deepEqual(store.read(user), valid);
store.write(null, valid); assert.equal(data.size, 2, 'no unauthenticated writes');
for (const raw of ['broken', 'x'.repeat(4097)]) {
+2 -4
View File
@@ -14,6 +14,7 @@ const ReportUI = require('../js/report-ui.js');
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 wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const fx = {};
vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
const fixture = fx.MOCK_FIXTURE;
@@ -35,14 +36,11 @@ try {
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 } });
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
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');
await page.click('[data-action="login-confirm"]');
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: 60, daily_skin_type: '干性肌肤' } } })).json();
assert.equal(result.ok, true);
+8 -7
View File
@@ -17,27 +17,28 @@ await page.waitForFunction(() => !document.querySelector('#login-message').textC
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"]');
await page.click('[data-action="login-wechat"]');
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.waitForFunction(() => document.querySelector('#login-message').textContent.includes('免登录'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.waitForSelector('#p-login-success.show');
await page.screenshot({ path: new URL('02-login-success.png', root).pathname });
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
await page.waitForTimeout(400);
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.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-login.active');
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="open-privacy"]');
await page.waitForTimeout(400);
+22 -19
View File
@@ -90,18 +90,19 @@ try {
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
let browserId, browserToken;
if (device === 0) {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
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'));
accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' });
assert.equal(browserToken, null);
assert.throws(() => accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' }));
await page.click('[data-action="history"]');
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
await page.click('[data-action="switch-login"]');
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('报告不会自动合并'));
assert((await page.locator('#login-message').textContent()).includes('后续检测报告'));
upstreamError = true;
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
@@ -140,26 +141,28 @@ try {
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'wechat');
assert.equal(await page.evaluate(() => AccountClient.user().id), firstId);
await page.click('[data-action="history"]');
await page.click('[data-action="switch-login"]');
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
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');
await confirmLogin(page, 'browser');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
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.waitForSelector('[data-report-id="RbrowserOriginal"]');
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, 404);
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(); // Ordinary browser restore is still supported.
await page.waitForSelector('#s-splash.active');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
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');
}
assert.deepEqual(errors, []);
@@ -179,7 +182,7 @@ try {
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 account switching, preserved browser reports, OAuth failure/missing-cookie handling, no silent browser fallback');
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();
+20
View File
@@ -0,0 +1,20 @@
// Test-only persisted WeChat sessions. Never import from production code.
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
module.exports = function seedWechat(root, count = 1) {
const file = path.join(root, 'identities.json');
if (fs.existsSync(file)) throw Error('Wechat fixture requires an empty test store');
const db = { users: [], sessions: {}, states: {} }, entries = [];
for (let i = 0; i < count; i++) {
const user = { id: 'fixture-wechat-' + i, kind: 'wechat', identity: 'fixture-' + i, reports: [] };
const token = crypto.randomBytes(32).toString('hex');
db.users.push(user);
db.sessions[crypto.createHash('sha256').update(token).digest('hex')] = { user: user.id, expires: Date.now() + 86400000 };
entries.push({ user, token, cookie: 'juzi_session=' + token });
}
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(file, JSON.stringify(db));
return entries;
};