'use strict'; const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'), path = require('node:path'), os = require('node:os'), net = require('node:net'); const { spawn } = require('node:child_process'); const { once } = require('node:events'); const root = path.resolve(__dirname, '../..'); async function freePort() { const server = net.createServer(); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port; } function seed(dir, channel, extra = false) { fs.mkdirSync(path.join(dir, 'photos', 'RCOLLIDE'), { recursive: true }); fs.mkdirSync(path.join(dir, 'results'), { recursive: true }); fs.mkdirSync(path.join(dir, 'accounts', 'reports', 'owner'), { recursive: true }); const row = { record_id: 'RCOLLIDE', created_at: '2026-11-02T01:00:00Z', updated_at: '2026-11-02T01:00:00Z', owner_id: 'owner', real_age: channel === 'h5' ? 24 : 41, detect_status: '失败', fail_code: channel + '_FAIL', img_front: 'data/photos/RCOLLIDE/front.jpg', raw_file: 'raw.json' }; if (channel === 'h5') row.presentation = { marker: channel }; // Old H5 rows have no channel; a Taobao source can also contain older rows. fs.writeFileSync(path.join(dir, 'records.jsonl'), JSON.stringify(row) + '\n' + (extra ? JSON.stringify({ ...row, record_id: 'RNEW', channel, created_at: '2026-11-02T02:00:00Z', detect_status: '检测中' }) + '\n' : '')); fs.writeFileSync(path.join(dir, 'photos', 'RCOLLIDE', 'front.jpg'), channel + '-photo'); fs.writeFileSync(path.join(dir, 'results', 'raw.json'), JSON.stringify({ marker: channel, result: { face_maps: { sample: 'base64-' + channel } } })); fs.writeFileSync(path.join(dir, 'accounts', 'reports', 'owner', 'RCOLLIDE.json'), JSON.stringify({ presentation: { marker: channel } })); return row; } function files(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() ? files(path.join(dir, e.name)) : [{ file: path.join(dir, e.name), content: fs.readFileSync(path.join(dir, e.name)).toString('base64') }]); } test('admin aggregates separate stores with channel filters, collision-safe details/media/export and no remote writes', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-admin-channels-')); const local = path.join(dir, 'h5'), remote = path.join(dir, 'taobao'); seed(local, 'h5'); seed(remote, 'taobao', true); const beforeRemote = files(remote), port = await freePort(), base = 'http://127.0.0.1:' + port; let stderr = ''; const child = spawn(process.execPath, ['server.js'], { cwd: path.join(root, 'h5'), env: { ...process.env, HOST: '127.0.0.1', PORT: String(port), NODE_ENV: 'test', JUZI_ENTRY_MODE: 'state', JUZI_DATA_ROOT: local, JUZI_ADMIN_TAOBAO_DATA_ROOT: remote, SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); child.stderr.on('data', value => { stderr += value; }); try { await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('server startup timed out: ' + stderr)), 15000); child.stdout.on('data', value => { if (String(value).includes('已启动')) { clearTimeout(timer); resolve(); } }); child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code + ': ' + stderr)); }); }); const json = async route => { const response = await fetch(base + route); assert.equal(response.status, 200, route); return response.json(); }; const first = await json('/api/admin/records?page_size=2'); assert.equal(first.total, 3); assert.equal(first.items[0].record_id, 'RNEW'); const second = await json('/api/admin/records?page_size=2&page=2'); assert.equal(new Set(first.items.concat(second.items).map(r => r.admin_key)).size, 3); const tb = await json('/api/admin/records?channel=taobao'); assert.equal(tb.total, 2); assert(tb.items.every(r => r.source === 'taobao' && r.readonly)); assert.equal((await json('/api/admin/records?channel=h5')).total, 1); assert.equal((await json('/api/admin/stats')).total, 3); const stats = await json('/api/admin/stats?channel=taobao'); assert.equal(stats.total, 2); assert.equal(stats.fail_total, 1); assert.equal(stats.by_channel.h5.total, 1); for (const source of ['local', 'taobao']) { const expected = source === 'local' ? 'h5' : 'taobao'; const detail = await json('/api/admin/records/RCOLLIDE?source=' + source); assert.equal(detail.raw_json.marker, expected); assert.equal(detail.record.presentation.marker, expected); assert.equal(detail.readonly, source === 'taobao'); assert.match(detail.raw_json.result.face_maps.sample, /base64 图层已省略/); assert.equal(await (await fetch(base + detail.photos.front)).text(), expected + '-photo'); assert.equal((await json('/api/admin/records/RCOLLIDE/raw?source=' + source)).marker, expected); } const csv = await (await fetch(base + '/api/admin/export?channel=taobao&detect_status=失败&age_min=40')).text(); const lines = csv.trim().split('\n'); assert.equal(lines.length, 2); assert.match(lines[0], /渠道,数据来源,记录唯一键/); assert.match(lines[1], /taobao,taobao,taobao:RCOLLIDE/); assert.equal(lines[0].replace(/^\uFEFF/, '').split(',')[2], '年龄'); assert.equal((await fetch(base + '/api/admin/records/RCOLLIDE/retry?source=taobao', { method: 'POST' })).status, 409); assert.equal((await fetch(base + '/api/admin/records/RCOLLIDE?source=../../outside')).status, 404); assert.equal((await fetch(base + '/api/admin/stats?channel=unknown')).status, 400); assert.equal((await fetch(base + '/api/admin/photos/RMISSING/front?source=taobao')).status, 404); assert.deepEqual(files(remote), beforeRemote, 'aggregation never modifies the external store'); assert(!JSON.stringify(first.sources).includes(dir), 'physical paths are never exposed to the browser'); if (process.env.JUZI_ADMIN_BROWSER_QA === '1') { const { chromium } = require('playwright'); const browser = await chromium.launch(); try { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }), errors = []; page.on('pageerror', e => errors.push(e.message)); await page.goto(base + '/admin.html'); await page.waitForFunction(() => document.querySelectorAll('#tbl-records tbody tr').length === 3); await page.selectOption('#f-channel', 'taobao'); await page.waitForFunction(() => document.querySelectorAll('#tbl-records tbody tr').length === 2); assert.match(await page.locator('#tbl-records').innerText(), /淘宝小程序/); await page.locator('#tbl-records tbody tr').filter({ hasText: 'RCOLLIDE' }).click(); await page.waitForFunction(() => document.querySelector('#d-body').textContent.includes('只读汇总')); assert(await page.locator('#btn-retry').isDisabled()); assert.match(await page.locator('.d-photo img').getAttribute('src'), /source=taobao$/); await page.click('#btn-close'); await page.click('[data-tab="stats"]'); await page.waitForFunction(() => document.querySelector('#channel-stats').textContent.includes('淘宝小程序')); assert.match(await page.locator('#stat-cards').innerText(), /累计检测\s*2/); const screenshots = path.join(root, 'temp', 'taobao-adaptation'); fs.mkdirSync(screenshots, { recursive: true }); await page.screenshot({ path: path.join(screenshots, 'admin-channels.png'), fullPage: true }); await page.click('[data-tab="fails"]'); await page.waitForFunction(() => document.querySelectorAll('#tbl-fails tbody tr').length === 1); assert.equal(await page.locator('#tbl-fails button').count(), 0); assert.deepEqual(errors, []); } finally { await browser.close(); } } } finally { if (child.exitCode === null) { const exited = once(child, 'exit'); child.kill(); await exited; } fs.rmSync(dir, { recursive: true, force: true }); } }); test('admin sources reject nested/public roots and cannot read arbitrary filesystem paths', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-admin-source-')); try { const local = path.join(dir, 'local'), remote = path.join(dir, 'remote'); fs.mkdirSync(local); fs.mkdirSync(remote); const factory = require('../../h5/lib/admin-sources'); assert.throws(() => factory({ localRoot: local, taobaoRoot: local }), /separate/); assert.throws(() => factory({ localRoot: local, taobaoRoot: remote, publicRoot: dir }), /outside/); const sources = factory({ localRoot: local, taobaoRoot: remote }); assert.equal(sources.file('taobao', '..', 'secret'), null); assert.equal(sources.file('anything', 'records.jsonl'), null); assert.equal(sources.find('RCOLLIDE', '../../local'), null); fs.writeFileSync(path.join(local, 'private.json'), 'private'); fs.symlinkSync(local, path.join(remote, 'escape'), process.platform === 'win32' ? 'junction' : 'dir'); assert.equal(sources.file('taobao', 'escape', 'private.json'), null, 'symlinks cannot escape the configured source'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } });