Files
juzi/taobao/test/admin-channels.test.cjs
T
xsl 873b45d3a5 fix: preserve legacy admin URLs across H5 channels
Keep local record and download paths compatible while retaining explicit external source selection. Record Taobao, H5, WeChat and rednote compatibility verification and baseline test failures.
2026-11-04 15:17:56 +08:00

129 lines
9.4 KiB
JavaScript

'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.equal(new URL(detail.photos.front, base).search, source === 'local' ? '' : '?source=taobao', 'local photo URLs retain their legacy shape; external sources remain explicit');
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 }); }
});