feat(taobao): add native mini program with shared backend

Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
This commit is contained in:
xsl
2026-10-19 20:14:31 +08:00
parent d07f8cc1bb
commit 9eec4369da
61 changed files with 4827 additions and 17 deletions
+160
View File
@@ -0,0 +1,160 @@
'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;
});
+145
View File
@@ -0,0 +1,145 @@
'use strict';
const { test } = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui');
const base = path.resolve(__dirname, '..');
function page(file, deps = {}, extra = {}) {
let definition; const timers = [];
const context = { Page(value) { definition = value; }, require(name) {
if (name in deps) return deps[name];
return require(path.resolve(path.dirname(path.join(base, file)), name));
}, setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout(id) { timers[id - 1] = null; },
Promise, console, Date, ...extra };
vm.runInNewContext(fs.readFileSync(path.join(base, file), 'utf8'), context);
const instance = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)),
setData(patch, cb) {
for (const [key, value] of Object.entries(patch)) {
const pieces = key.replace(/\[(\d+)\]/g, '.$1').split('.'); let target = this.data;
for (const piece of pieces.slice(0, -1)) target = target[piece];
target[pieces.at(-1)] = value;
}
if (cb) cb();
}
});
return { instance, timers, context };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('offline camera debug skips identity/profile, never uploads or plays remote voice, and clears previews on exit', async () => {
let backendCalls = 0, voiceCalls = 0, exited = 0, authReads = 0;
const voice = { play() { voiceCalls++; }, stop() {}, VOICE: { step: [] } };
const camera = page('pages/camera/camera.js', {
'../../utils/development': { cameraDebugEnabled: () => true },
'../../utils/api': { user() { authReads++; return null; }, analyze() { backendCalls++; throw Error('debug must not upload'); } },
'../../utils/voice': voice,
'../../utils/permissions': { settings: async () => ({}), state: () => undefined, ensure: async () => {} },
'../../utils/image': { compressToBase64: async (photo, p) => {
p.setData({ compressWidth: 1280, compressHeight: 960 }); return 'data:image/jpeg;base64,/9j/';
} }
}, { getApp() { throw Error('debug must not read/write global report state'); },
my: { navigateBack() { exited++; }, redirectTo() { throw Error('debug must not redirect to login/report'); }, showToast() {} } });
const p = camera.instance;
p.onLoad({ debug: 'camera' }); p.onReady(); p.onShow(); await flush();
assert.equal(p.data.debugMode, true); assert.equal(authReads, 0);
for (const [i, photo] of ['front.jpg', 'right.jpg', 'left.jpg'].entries()) {
p.setData({ previewUrl: photo }); p.onConfirm(); if (i < 2) camera.timers[i]();
}
await flush(); assert.equal(p.data.debugComplete, true);
assert.deepEqual(Array.from(p.data.debugPhotos, v => v.key), ['front', 'right', 'left']);
assert(p.data.debugPhotos.every(v => v.width === 1280 && v.height === 960));
assert.equal(backendCalls, 0); assert.equal(voiceCalls, 0);
p.onCancel(); assert.equal(p.data.debugPhotos.length, 0); assert.equal(p.photos.length, 0); assert.equal(exited, 1);
});
test('camera debug URL cannot bypass normal login when the development gate is off', () => {
let redirected = 0;
const p = page('pages/camera/camera.js', {
'../../utils/development': { cameraDebugEnabled: () => false },
'../../utils/api': { user: () => null }
}, { my: { redirectTo() { redirected++; } } }).instance;
p.onLoad({ debug: 'camera' }); assert.equal(redirected, 1); assert.equal(p.data.debugMode, false);
});
test('development entry requires an explicit flag, development environment and local direct transport', () => {
const config = require('../config'), original = { ...config }, development = require('../utils/development');
try {
Object.assign(config, { environment: 'development', cameraDebug: true, transport: 'direct', baseUrl: 'http://127.0.0.1:18180' });
assert.equal(development.cameraDebugEnabled(), true);
for (const patch of [{ environment: 'production' }, { cameraDebug: false }, { transport: 'cloud' }, { baseUrl: 'https://skin.example.test' }]) {
const saved = { ...config }; Object.assign(config, patch); assert.equal(development.cameraDebugEnabled(), false); Object.assign(config, saved);
}
} finally { Object.assign(config, original); }
});
test('Taobao report uses current H5 totals, displayed age, names, seven views, care selection and product', () => {
const report = require('../utils/report-model');
for (const realAge of [24, 44, 60]) {
const result = structuredClone(SS.MOCK_RESULT), side = structuredClone(SS.MOCK_SIDE);
const model = SS.buildViewModel(result, side, { realAge, skinType: '干性肌肤' });
const display = UI.build(model, result), bytes = JSON.stringify(model);
const rendered = report.model({ result, side, vm: model, presentation: display }, ['front'], {});
assert.equal(rendered.total, SS.weightedTotal(model.dims)); assert.equal(rendered.skinAge, SS.reportSkinAge(model, result));
assert.deepEqual(rendered.dims.map(d => d.name), ['紧塑力', '透亮感', '稳韧力']);
assert.deepEqual(rendered.views.map(v => v.key), ['contour', 'wrinkles', 'color', 'smooth', 'water', 'sens', 'acne']);
assert.deepEqual(rendered.careAdvice, display.care); assert.equal(rendered.product?.tier, display.product?.tier);
assert.equal(JSON.stringify(model), bytes, 'rendering does not mutate saved scores');
}
assert.deepEqual(report.marks({ acne: { rectangle: [{ left: 10, top: 20, width: 20, height: 40 }] } }, 'acne', 100, 200)[0],
{ id: 0, left: 10, top: 10, width: 20, height: 20, color: '#D30C0C' });
assert.equal(report.mapURL('https://untrusted.example/fake-map'), '');
});
test('camera blocks duplicate shots/confirm, cancels late callbacks and submits correct left/right order with one shared canvas', async () => {
let captured, calls = 0, submitted = 0, ordered, resolver, navigated = 0;
const app = { globalData: { profile: { real_age: 30 } } };
const api = { user: () => ({ id: 'a' }), analyze: (photos) => { submitted++; ordered = photos; return new Promise(resolve => { resolver = resolve; }); } };
const voice = { play() {}, stop() {}, VOICE: { step: [], shot: '', analyzing: '', fail: '' } };
const camera = page('pages/camera/camera.js', { '../../utils/api': api, '../../utils/voice': voice,
'../../utils/permissions': { settings: async () => ({ 'scope.camera': true }), state: (s, key) => s[key], ensure: async () => {} },
'../../utils/image': { compressToBase64: async src => src + '-base64' } }, {
getApp: () => app, my: { createCameraContext(id) { assert.equal(id, 'skin-camera'); return { takePhoto(opts) { captured = opts; calls++; } }; }, showToast() {}, redirectTo() { navigated++; } }
});
const p = camera.instance; p.onLoad(); p.onReady(); p.onShow();
await flush();
p.onShutter(); p.onShutter(); assert.equal(calls, 1);
captured.success({ tempImagePath: 'front' }); captured.complete(); p.onConfirm(); p.onConfirm();
assert.equal(p.data.stepIndex, 1); camera.timers[0]();
p.setData({ previewUrl: 'right' }); p.onConfirm(); camera.timers[1]();
p.setData({ previewUrl: 'left' }); p.onConfirm(); p.onConfirm();
await flush(); assert.equal(submitted, 1); assert.deepEqual(Array.from(ordered), ['front-base64', 'left-base64', 'right-base64']);
p.onUnload(); resolver({ result: {} }); await flush(); assert.equal(navigated, 0, 'late result cannot navigate after leaving camera');
assert.equal(app.globalData.payload, undefined);
});
test('image pipeline resizes large images to at most 1280 and exports real JPEG before base64 reading', async () => {
let module = { exports: {} }, dimensions, exported;
const context = { module, Promise, my: {
getImageInfo(opts) { opts.success({ width: 4000, height: 3000 }); },
createCanvasContext() { return { clearRect() {}, drawImage(src, x, y, w, h) { dimensions = [w, h]; },
draw(reserve, cb) { cb(); }, toTempFilePath(opts) { exported = opts; opts.success({ apFilePath: 'resized.jpg' }); } }; },
getFileSystemManager() { return { readFile(opts) { assert.equal(opts.filePath, 'resized.jpg'); opts.success({ data: '/9j/test' }); } }; }
} };
vm.runInNewContext(fs.readFileSync(path.join(base, 'utils/image.js'), 'utf8'), context);
const result = await module.exports.compressToBase64('raw.heic', { setData(data, cb) { cb(); } });
assert.deepEqual(dimensions, [1280, 960]); assert.equal(exported.fileType, 'jpg'); assert.equal(exported.quality, 0.88);
assert.equal(result, 'data:image/jpeg;base64,/9j/test');
});
test('province changes reset cities, canceled choices do not leak into reopening, and profiles stay account scoped', () => {
let saved;
const api = { assetUrl: v => v, user: () => ({ id: 'account-a' }) }, app = { globalData: {} };
const p = page('pages/profile/profile.js', { '../../utils/api.js': api }, { getApp: () => app,
my: { setStorageSync(value) { saved = value; }, navigateTo() {}, getStorageSync() { return {}; } } }).instance;
p.onLoad(); p.onOpenRegion(); p.onPickerChange({ detail: { value: [2, 8] } });
assert.equal(p.data.pickerValue[1], 0); p.onPickerConfirm(); assert.equal(p.data.regionIndex[1], 0);
const committedCities = Array.from(p.data.regionRange[1]);
p.onOpenRegion(); p.onPickerChange({ detail: { value: [3, 2] } }); p.onPickerClose();
p.onOpenRegion();
assert.deepEqual(Array.from(p.data.pickerValue), [2, 0]);
assert.deepEqual(Array.from(p.data.regionRange[1]), committedCities, 'reopened city list must match the committed province');
p.setData({ birth: '1996-01', age: 30, birthText: '1996年1月', genderText: '女' });
p.onStartCamera(); assert.equal(saved.key, 'juzi.taobao.profile.account-a'); assert.equal(app.globalData.profile.real_age, 30);
});
test('native AXML event handlers resolve and all routes have JS/AXML/ACSS/JSON files', () => {
const pages = require('../app.json').pages;
for (const route of pages) {
for (const ext of ['js', 'axml', 'acss', 'json']) assert(fs.existsSync(path.join(base, route + '.' + ext)), route + '.' + ext);
const source = fs.readFileSync(path.join(base, route + '.js'), 'utf8');
const markup = fs.readFileSync(path.join(base, route + '.axml'), 'utf8');
for (const match of markup.matchAll(/(?:on[A-Z]\w*|catch[A-Z]\w*)="([A-Za-z]\w*)"/g)) {
assert(new RegExp('\\b' + match[1] + '\\s*:\\s*function').test(source), route + ' missing ' + match[1]);
}
}
});
+14
View File
@@ -0,0 +1,14 @@
'use strict';
const { spawnSync } = require('node:child_process'), path = require('node:path'), fs = require('node:fs');
const root = path.resolve(__dirname, '../..'), artifacts = path.join(root, 'temp/taobao-h5-compatibility');
const gitOpenSSL = 'C:\\Program Files\\Git\\usr\\bin';
const testEnv = { ...process.env, H5_ARTIFACT_DIR: artifacts };
if (process.platform === 'win32' && fs.existsSync(path.join(gitOpenSSL, 'openssl.exe'))) testEnv.Path = gitOpenSSL + path.delimiter + (process.env.Path || process.env.PATH);
fs.mkdirSync(artifacts, { recursive: true });
for (const name of ['guest-privacy.mjs', 'wechat-browser.mjs', 'report-persistence.mjs', 'wrinkle-async-browser.mjs']) {
console.log('H5 compatibility: ' + name);
const result = spawnSync(process.execPath, ['--require', path.join(__dirname, 'legacy-playwright.cjs'), path.join(root, 'h5/test', name)],
{ cwd: root, env: testEnv, stdio: 'inherit', timeout: 120000 });
if (result.status !== 0) { if (result.error) console.error(result.error.message); process.exit(result.status || 1); }
}
console.log('PASS: H5 guest privacy, WeChat /state/ OAuth flow, full report persistence and async wrinkle layers');
+12
View File
@@ -0,0 +1,12 @@
// Resolve the repository's unmodified H5 browser tests against this project's
// local Playwright installation, instead of requiring a global npm install.
const cp = require('node:child_process'), path = require('node:path');
const original = cp.execSync;
cp.execSync = function (command, options) {
if (command === 'npm root -g') {
const value = path.resolve(__dirname, '../node_modules') + '\n';
return options && options.encoding ? value : Buffer.from(value);
}
return original.apply(this, arguments);
};
require('node:module').syncBuiltinESMExports();
+30
View File
@@ -0,0 +1,30 @@
'use strict';
const { test } = require('node:test'), assert = require('node:assert/strict'), vm = require('node:vm'), fs = require('node:fs'), path = require('node:path');
function setup(my) {
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/permissions.js'), 'utf8'), { module, Promise, Error, my });
return module.exports;
}
test('camera and album authorization are separate from login; deny never loops and only explicit settings can restore', async () => {
let granted = { 'scope.userInfo': true }, calls = [], openCalls = 0;
const permissions = setup({
getSetting({ success }) { success({ authSetting: granted }); },
authorize({ scopes, success, fail }) { calls.push(scopes); if (scopes === 'scope.camera') fail(); else success(); },
openSetting({ success }) { openCalls++; granted['scope.camera'] = true; success({ authSetting: granted }); }
});
await assert.rejects(permissions.ensure('scope.camera'), e => e.permission === 'scope.camera');
await assert.rejects(permissions.ensure('scope.camera'), e => e.permission === 'scope.camera');
assert.deepEqual(calls, ['scope.camera']); assert.equal(openCalls, 0);
await permissions.ensure('scope.album'); assert.deepEqual(calls, ['scope.camera', 'scope.album']);
await permissions.open(); await permissions.ensure('scope.camera');
assert.equal(calls.length, 2); assert.equal(openCalls, 1);
});
test('existing denial does not reauthorize; short-form settings keys work and unknown scope requests on demand', async () => {
let calls = 0;
const permissions = setup({ getSetting({ success }) { success({ authSetting: { camera: false, album: true } }); },
authorize() { calls++; } });
await assert.rejects(permissions.ensure('scope.camera')); await permissions.ensure('scope.album'); assert.equal(calls, 0);
const first = setup({ getSetting({ success }) { success({ authSetting: {} }); },
authorize({ scopes, success }) { assert.equal(scopes, 'scope.camera'); calls++; success(); } });
await first.ensure('scope.camera'); assert.equal(calls, 1);
});
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const { test } = require('node:test'), assert = require('node:assert/strict'), crypto = require('node:crypto');
const auth = require('../../h5/lib/taobao-auth');
test('gateway signature covers raw JSON, identity, extra query fields and algorithm; duplicates fail closed', () => {
const env = { TAOBAO_APP_KEY: 'app', TAOBAO_APP_SECRET: 'secret' }, body = '{"consent":true,"challenge":"nonce"}';
const params = { app_key: 'app', open_id: 'open-account', sign_method: 'md5', env: 'online' };
const expected = crypto.createHash('md5').update('secretapp_keyappenvonlineopen_idopen-accountsign_methodmd5' + body + 'secret').digest('hex').toUpperCase();
assert.equal(auth.signature(params, body, env.TAOBAO_APP_SECRET), expected);
const url = '/api/taobao/session?' + new URLSearchParams({ ...params, sign: expected });
assert(auth.verify(url, body, env));
assert.equal(auth.verify(url, body + ' ', env), null);
assert.equal(auth.verify(url.replace('open-account', 'forged-account'), body, env), null);
assert.equal(auth.verify(url + '&open_id=open-account', body, env), null);
assert.equal(auth.verify(url, body, {}), null);
});
test('photo chunks are ordered, bounded, deduplicated, owner-bound, expiring and consumed once', async () => {
let now = 0;
const req = { headers: {}, owner: 'alice' }, other = { headers: {}, owner: 'bob' };
const runtime = require('../../h5/lib/taobao-session')({ now: () => now, session: request => ({ id: request.owner, kind: 'taobao' }) });
const piece = { view: 'front', upload_id: 'test-upload-id', index: 0, total: 2, data: 'AAAA' };
assert.equal(runtime.upload(req, { ...piece, index: 1 }).status, 409);
assert.equal(runtime.upload(req, piece).complete, false);
assert.equal(runtime.upload(req, piece).complete, false, 'repeated identical chunk is idempotent');
assert.equal(runtime.upload(req, { ...piece, data: 'BBBB' }).status, 409);
assert.equal(runtime.upload(req, { ...piece, data: 'A'.repeat(65537) }).status, 400);
assert.equal(runtime.upload(req, { ...piece, index: 1 }).complete, true);
assert.equal(runtime.takePhotos(other, { front: piece.upload_id }), null);
for (const view of ['left', 'right']) assert.equal(runtime.upload(req, { ...piece, view, total: 1 }).status, 200);
const refs = { front: piece.upload_id, left: piece.upload_id, right: piece.upload_id };
assert(runtime.takePhotos(req, refs)); assert.equal(runtime.takePhotos(req, refs), null);
assert.equal(runtime.upload(req, piece).status, 409);
let complete;
const job = runtime.start(req, () => new Promise(resolve => { complete = resolve; }), true);
await new Promise(resolve => setImmediate(resolve));
assert.equal(runtime.result(other, job), null); assert.equal(runtime.result(req, job).status, 'running');
complete({ ok: true }); await new Promise(resolve => setImmediate(resolve));
assert.equal(runtime.result(req, job).status, 'complete');
assert.equal(runtime.upload(req, piece).status, 200);
now = 5 * 60000 + 1; assert.equal(runtime.takePhotos(req, refs), null);
const challenge = runtime.challenge('local'); assert(runtime.consume(challenge)); assert(!runtime.consume(challenge));
});
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const { test } = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
test('logout succeeds for an expired session, while a failed server revocation remains visible', async () => {
const storage = new Map(), app = { globalData: {} };
let logoutFailure;
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/api.js'), 'utf8'), {
module, Promise, Date, setTimeout, getApp: () => app,
my: { setStorageSync: ({ key, data }) => storage.set(key, data),
getStorageSync: ({ key }) => ({ data: storage.get(key) }), removeStorageSync: ({ key }) => storage.delete(key) },
require(name) {
if (name === '../config') return { transport: 'direct' };
if (name === './transport') return { request: async route => {
if (route.endsWith('/challenge')) return { ok: true, challenge: 'challenge' };
if (route.endsWith('/session')) return { ok: true, token: 'token', user: { id: 'alice' } };
if (route.endsWith('/logout')) {
if (logoutFailure === 'network') throw Error('network unavailable');
return { ok: false, http_status: 401, message: 'session expired' };
}
throw Error('unexpected route: ' + route);
} };
throw Error('unexpected dependency: ' + name);
}
});
const api = module.exports;
await api.login(true);
logoutFailure = 'network';
await assert.rejects(api.logout(), /network unavailable/);
assert.equal(api.user().id, 'alice');
app.globalData.photos = ['private-photo']; app.globalData.payload = { private: true };
logoutFailure = 'expired';
await api.logout();
assert.equal(api.user(), null); assert.equal(storage.size, 0);
assert.equal(app.globalData.photos, null); assert.equal(app.globalData.payload, null);
});
+21
View File
@@ -0,0 +1,21 @@
'use strict';
const { test } = require('node:test'), assert = require('node:assert/strict'), vm = require('node:vm'), fs = require('node:fs'), path = require('node:path');
test('cloud transport initializes once, preserves subpath/auth, caps timeout and unwraps gateway envelopes', async () => {
const config = { transport: 'cloud', baseUrl: 'https://skin.example.test', basePath: '/state', cloudAppId: 'cloud-id', cloudEnv: 'online' };
let initialized = 0, options;
const cloud = { init: async ({ env }) => { assert.equal(env, 'online'); initialized++; return true; },
application: { httpRequest: async input => { options = input; return { ok: true, http_status: 200 }; } } };
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/transport.js'), 'utf8'), {
module, Promise, require(name) { return name === '../config' ? config : { default: cloud }; }
});
const transport = module.exports;
await transport.request('/api/taobao/me', {}, 'token'); await transport.request('/api/taobao/reports', {}, 'token');
assert.equal(initialized, 1); assert.equal(options.path, '/state/api/taobao/reports');
assert.equal(options.headers.Authorization, 'Bearer token'); assert.equal(options.exts.timeout, 10000);
assert.equal(options.exts.cloudAppId, 'cloud-id'); assert.equal(options.exts.domain, config.baseUrl);
assert.equal(transport.unwrap({ success: true, data: { ok: true } }).ok, true);
assert.throws(() => transport.unwrap({ success: false, errorMessage: 'gateway rejected' }), /gateway rejected/);
config.transport = 'direct';
await assert.rejects(transport.request('/api/taobao/me', {}), /仅用于本机/);
});