// 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 }); }