'use strict'; const { test, before, after } = require('node:test'); const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); const { spawn } = require('node:child_process'), { once } = require('node:events'); const root = path.resolve(__dirname, '../..'), auth = require('../../h5/lib/taobao-auth'); const env = { TAOBAO_APP_KEY: 'fixture-app', TAOBAO_APP_SECRET: 'fixture-secret' }; const dir = fs.mkdtempSync(path.join(root, 'temp/taobao-integration-')); const port = 18201, base = 'http://127.0.0.1:' + port; let child, wechat, providerCalls = 0; const fixtureContext = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fixtureContext); const fixture = fixtureContext.window.MOCK_FIXTURE; async function post(route, body = {}, token, params, cookie) { const raw = JSON.stringify(body), q = { ...params }; if (params) q.sign = auth.signature(q, raw, env.TAOBAO_APP_SECRET); const res = await fetch(base + route + (params ? '?' + new URLSearchParams(q) : ''), { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(cookie ? { cookie } : {}) }, body: raw }); return { response: res, data: await res.json() }; } async function login(openId) { const challenge = (await post('/api/taobao/challenge')).data.data.challenge; const body = { consent: true, challenge }; const params = { app_key: env.TAOBAO_APP_KEY, open_id: openId, sign_method: 'md5' }; const result = await post('/api/taobao/session', body, null, params); assert.equal(result.data.data.ok, true); return { ...result.data.data, body, params }; } async function upload(token) { const refs = {}; for (const [i, view] of ['front', 'left', 'right'].entries()) { const b64 = (fixture.photos[i] || fixture.photos[0]).split(',')[1], total = Math.ceil(b64.length / 65536), id = view + '-fixture-' + Date.now(); for (let index = 0; index < total; index++) { const result = await post('/api/taobao/photo', { view, upload_id: id, index, total, data: b64.slice(index * 65536, (index + 1) * 65536) }, token); assert.equal(result.data.data.ok, true); assert.equal(result.data.data.complete, index === total - 1); } refs[view] = id; } return refs; } async function poll(token, job) { for (let i = 0; i < 60; i++) { const result = (await post('/api/taobao/analysis', { job }, token)).data.data; if (result.status === 'complete') return result.payload; await new Promise(resolve => setTimeout(resolve, 30)); } throw Error('job did not finish'); } before(async () => { wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(dir, 'accounts'))[0]; 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}); global.fetch=async()=>{fs.appendFileSync(process.env.JUZI_DATA_ROOT+'/provider-count','1\\n');await new Promise(r=>setTimeout(r,120));return {ok:true,status:200,json:async()=>({request_id:'fixture',result:structuredClone(w.MOCK_FIXTURE.result),face_rectangle:w.MOCK_FIXTURE.face_rectangle,left_side_result:w.MOCK_FIXTURE.side.left,right_side_result:w.MOCK_FIXTURE.side.right})};}; require('./server'); `], { cwd: path.join(root, 'h5'), env: { ...process.env, ...env, NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1', JUZI_DATA_ROOT: dir, PUBLIC_BASE_URL: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('backend startup timeout')), 15000); child.stdout.on('data', data => { if (data.toString().includes('已启动')) { clearTimeout(timer); resolve(); } }); child.once('exit', code => { clearTimeout(timer); reject(Error('backend exited ' + code)); }); child.stderr.on('data', data => process.stderr.write(data)); }); }); after(async () => { if (child && child.exitCode == null) { const exit = once(child, 'exit'); child.kill(); await exit; } assert(dir.startsWith(path.join(root, 'temp') + path.sep)); fs.rmSync(dir, { recursive: true, force: true }); }); test('signed Taobao identities, replay defense, H5 isolation, shared report contract, persisted history and channel stats', async () => { const unsigned = (await post('/api/taobao/session', { consent: true, challenge: 'fake', open_id: 'forged-user' })).data.data; assert.equal(unsigned.http_status, 401); const alice = await login('fixture-alice'), bob = await login('fixture-bob'); assert.notEqual(alice.user.id, bob.user.id); assert.equal((await login('fixture-alice')).user.id, alice.user.id, 'same platform account maps to stable identity'); assert.equal((await post('/api/taobao/session', alice.body, null, alice.params)).data.data.http_status, 401, 'signed login cannot replay'); const h5Session = await (await fetch(base + '/api/auth/session', { headers: { cookie: 'juzi_session=' + alice.token } })).json(); assert.equal(h5Session.user, null, 'Taobao credentials cannot become a WeChat/H5 cookie'); assert.equal((await post('/api/taobao/me', {}, wechat.token)).data.data.http_status, 401); const refs = await upload(alice.token); assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs }, bob.token)).data.data.http_status, 409, 'photos are owner bound'); const profile = { real_age: 44, gender: '女', region: '上海市', daily_skin_type: '干性肌肤' }; const started = (await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data; assert.equal(started.http_status, 202); assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data.http_status, 409, 'consumed photos cannot charge twice'); assert.equal((await post('/api/taobao/analysis', { job: started.job }, bob.token)).data.data.http_status, 404); const result = await poll(alice.token, started.job); assert.equal(result.ok, true); assert.equal(result.report_saved, true); const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui'); const expected = SS.buildViewModel(result.result, result.side, { realAge: 44, skinType: '干性肌肤' }); assert.deepEqual(result.vm, expected); assert.deepEqual(result.presentation, UI.build(expected, result.result)); assert.equal(result.presentation.views.length, 7); assert.equal(result.presentation.care.length, 3); assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, 1, 'primary report does not start side calls'); const history = (await post('/api/taobao/reports', {}, alice.token)).data.data; assert.equal(history.total, 1); assert.equal((await post('/api/taobao/reports', { id: result.record_id }, bob.token)).data.data.http_status, 404); assert.equal((await post('/api/taobao/reports', {}, bob.token)).data.data.total, 0); assert.equal((await post('/api/taobao/reports', { id: result.record_id }, alice.token)).data.data.report.id, result.record_id); const sideJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data; assert.equal((await poll(alice.token, sideJob.job)).ok, true); const count = fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length; const cachedJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data; await poll(alice.token, cachedJob.job); assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, count, 'side requests are cached'); const h5Body = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile }; const h5Wechat = await post('/api/skin-analyze', h5Body, null, null, 'juzi_session=' + wechat.token); assert.equal(h5Wechat.data.ok, true); assert.equal(h5Wechat.data.report_saved, true); assert.deepEqual(h5Wechat.data.vm, result.vm); assert.deepEqual(h5Wechat.data.presentation, result.presentation); const guestLogin = await post('/api/auth/browser', { consent: true }); const cookie = guestLogin.response.headers.getSetCookie().map(c => c.split(';')[0]).join(';'); const recordsBefore = fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8'); const guest = await post('/api/skin-analyze', { ...h5Body, channel: 'taobao' }, null, null, cookie); assert.equal(guest.data.ok, true); assert.equal(guest.data.report_saved, false); assert.equal(fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8'), recordsBefore, 'spoofed channel cannot change H5 guest retention'); const stats = await (await fetch(base + '/api/admin/stats')).json(); assert.equal(stats.by_channel.taobao.total, 1); assert.equal(stats.by_channel.h5.total, 1); const filtered = await (await fetch(base + '/api/admin/records?channel=taobao')).json(); assert.equal(filtered.total, 1); assert.equal(filtered.items[0].channel, 'taobao'); const csv = await (await fetch(base + '/api/admin/export?channel=taobao')).text(); const csvLines = csv.trim().split('\n'); assert.equal(csvLines.length, 2); assert.equal(csvLines[0].split(',')[2], '年龄', 'existing CSV column positions remain stable'); assert.equal(csvLines[0].split(',').at(-1).trim(), '渠道'); assert.equal(csvLines[1].split(',').at(-1).trim(), 'taobao'); const oldAccounts = require('../../h5/lib/accounts')(path.join(dir, 'accounts'), {}); assert.equal(oldAccounts.taobaoSession({ headers: { authorization: 'Bearer ' + alice.token } }).id, alice.user.id, 'restart reloads account/session'); assert.equal(oldAccounts.reports(alice.user.id).length, 1); await post('/api/taobao/logout', {}, bob.token); assert.equal((await post('/api/taobao/me', {}, bob.token)).data.data.http_status, 401); }); test('native API client logs in, uploads within gateway limit, polls one detection, restores identity and honors cancellation', async () => { const config = require('../config'), storage = new Map(); config.baseUrl = base; config.transport = 'direct'; config.basePath = ''; let requests = 0; global.my = { setStorageSync({ key, data }) { storage.set(key, data); }, getStorageSync({ key }) { return { data: storage.get(key) }; }, removeStorageSync({ key }) { storage.delete(key); }, request(options) { requests++; assert(Buffer.byteLength(options.data) < 128 * 1024); const url = new URL(options.url); if (url.pathname === '/api/taobao/session') { const params = { app_key: env.TAOBAO_APP_KEY, open_id: 'native-client-user', sign_method: 'md5' }; params.sign = auth.signature(params, options.data, env.TAOBAO_APP_SECRET); url.search = new URLSearchParams(params).toString(); } fetch(url, { method: options.method, headers: options.headers, body: options.data }).then(async res => { options.success({ status: res.status, data: await res.json() }); }).catch(options.fail); } }; const api = require('../utils/api'); const user = await api.login(true); assert.equal(user.kind, 'taobao'); const result = await api.analyze([fixture.photos[0], fixture.photos[0], fixture.photos[0]], { real_age: 30 }, { canceled: false }); assert.equal(result.ok, true); assert.equal(result.report_saved, true); assert.equal((await api.request('reports')).total, 1); assert.equal((await api.restore()).id, user.id); const before = requests; await assert.rejects(api.analyze(['unused', 'unused', 'unused'], {}, { canceled: true }), err => err.canceled === true); assert.equal(requests, before, 'cancelled pipeline does not send photos or start a paid job'); await api.logout(); assert.equal(await api.restore(), null); delete global.my; });