From bf8a985f22d881f59fa2a1a6ccdb0d9a5277fa61 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 15:46:42 +0800 Subject: [PATCH] test(order): add readonly merchant workbench pagination acceptance --- scripts/README.md | 2 + scripts/client-workbench-test-env.mjs | 154 +++++++++++++++++++++ scripts/client-workbench-test-env.test.mjs | 77 +++++++++++ 3 files changed, 233 insertions(+) create mode 100644 scripts/client-workbench-test-env.mjs create mode 100644 scripts/client-workbench-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 2ec1b1597..47f0477bd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`client-workbench-test-env.mjs` 是客户订单工作台只读 TEST 分页验收入口,默认 `--plan` 零网络。Firstleg/Order 新工作台接口部署后,以显式 `node --env-file=<用户指定目录的test-acceptance.env> scripts/client-workbench-test-env.mjs --execute --environment=TEST --run-id=<新批次>` 执行;独立客户登录及退出是唯一 POST,不创建订单、不写 SQL、不调用会补建预入库记录的旧草稿列表。要求稳定样本超过 200 条,遍历完整工作台及独立统一订单集合,核对完整正式单集合、费用/草稿待办、报关队列分页数量、21页切片、渠道筛选和伪造客户参数。报关业务资格、第二客户负向权限、页面及写后刷新仍需另验。报告独占 `reports/client-workbench-/`,请求前持久记录脱敏意图,失败保留且结束退出自己的会话;不保存凭据或原始客户明细。`client-workbench-test-env.test.mjs` 用故障响应验证拒绝截断、重复、错页和客户泄漏,不能替代真实 TEST 结果。 + `fleet-operations-test-env.mjs` 是车次运营统计独立只读TEST入口。显式环境文件及`--ready-commit=a6fd0ba3`之外,必须提供`--fixture=reports//handover-fixture.json --fixture-sha256=<文件SHA256> --marker=<该轮marker> --run-id=<独立批次>`;校验本轮真实DEPART和已核平车次成本,不能借历史车次或从统计响应反推期望。独立登录,只读该车次/历史/运营统计及导出,禁止造数、业务写入和里程更正;登录/退出为唯一POST。报告`reports/fleet-operations-/`独占且请求前落盘,原币成本CNY100.03与USD13.07分开;失败保留证据、禁止覆盖重跑,结束退出独立会话。来源断言和权限由正式入口及`fleet-operations-test-env-contract.test.mjs`共同约束,离线测试不证明真实统计已通过。 `fleet-quotes-test-env.mjs` 是独立报价TEST验收入口:显式使用 `node --env-file=<用户指定目录的test-acceptance.env> scripts/fleet-quotes-test-env.mjs --ready-commit=a6fd0ba3`,仅在总控确认匹配服务健康后执行。使用独立登录和本轮客户/供应商/报价,不借其他任务会话;固定marker为`FLEET-QUOTE-20261003`,报告目录`reports/fleet-quote-live-20261003`独占。登录、客户前置和业务请求均由脱敏持久化日志包裹,发送前fsync原请求/幂等键与文件样本引用;结果未知停止业务,保留原样本,拒绝自动重跑/续跑/覆盖。结束退出独立会话,不执行SQL、部署或库存操作。该入口只证明报价范围,运营统计仍须本轮真实DEPART及独立预期,见模块README。 diff --git a/scripts/client-workbench-test-env.mjs b/scripts/client-workbench-test-env.mjs new file mode 100644 index 000000000..ea9b845a8 --- /dev/null +++ b/scripts/client-workbench-test-env.mjs @@ -0,0 +1,154 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const endpoint = '/merchant/v1/orders/workbench' +class VerificationError extends Error {} +function requireFact(value, message) { if (!value) throw new VerificationError(message) } +const ids = rows => rows.map(row => String(row.orderId)) +const equalIds = (a, b) => JSON.stringify(ids(a).sort()) === JSON.stringify(ids(b).sort()) +const digest = rows => createHash('sha256').update(JSON.stringify(ids(rows).sort())).digest('hex') + +export function executionOptions(args, execArgs, env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false } + requireFact(args.length === 3 && args[0] === '--execute' && args[1] === '--environment=TEST' + && /^--run-id=[A-Za-z0-9_-]{1,60}$/.test(args[2]), 'Explicit TEST execution and a new run-id are required') + const files = execArgs.filter(arg => arg.startsWith('--env-file=')) + requireFact(files.length === 1 && execArgs.length === 1 && resolve(files[0].slice(11)).replaceAll('\\', '/') + .endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Use the explicitly authorized test-acceptance.env') + requireFact(String(env.CHONGCHONG_ENV).toUpperCase() === 'TEST', 'TEST profile required') + for (const key of ['CHONGCHONG_MERCHANT_API_BASE_URL', 'CHONGCHONG_MERCHANT_USERNAME', 'CHONGCHONG_MERCHANT_PASSWORD']) + requireFact(Boolean(env[key]), `Missing ${key}`) + const base = new URL(env.CHONGCHONG_MERCHANT_API_BASE_URL) + requireFact(['http:', 'https:'].includes(base.protocol) && !base.username && !base.password + && base.pathname.replace(/\/$/, '') === '/api', 'Expected the configured TEST API base') + return { execute: true, runId: args[2].slice(9), base: base.href.replace(/\/$/, '') } +} + +export function validatePage(page, number, size) { + const total = Number(page?.total), pages = Number(page?.pages) + requireFact(Number.isSafeInteger(total) && total >= 0 && Number(page.page) === number + && Number(page.size) === size && pages === Math.ceil(total / size), 'Invalid page metadata') + requireFact(Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, total - (number - 1) * size)), 'Truncated page') + requireFact(page.records.every(row => row.orderId != null) && new Set(ids(page.records)).size === page.records.length, 'Missing or duplicate order identity') + return page +} + +/** GET-only verifier. Caller owns authentication and durable, sanitized request evidence. */ +export async function verifyWorkbench(read, customerId) { + const checks = [] + const check = (name, ok) => { requireFact(ok, name); checks.push({ name, passed: true }) } + async function all(path, workbench = false, params = {}) { + let rows = [], expected = null, first + for (let number = 1; ; number++) { + const query = new URLSearchParams({ ...params, page: String(number), size: '200' }) + const response = await read(`${path}?${query}`) + const page = validatePage(workbench ? response.page : response, number, 200) + if (number === 1) { expected = Number(page.total); first = response } + requireFact(Number(page.total) === expected, 'Dataset changed while paging; rerun in a stable window') + requireFact(page.records.every(row => String(row.tenantId) === String(customerId)), 'Customer scope mismatch') + rows.push(...page.records) + if (number >= Number(page.pages)) break + requireFact(number < 1000, 'Pagination safety bound exceeded') + } + requireFact(rows.length === expected && new Set(ids(rows)).size === expected, 'Duplicate or missing orders across pages') + return { rows, first } + } + const workbench = await all(endpoint, true) + check('sample actually exceeds the former 200-order limit', workbench.rows.length > 200) + const formal = await all('/merchant/v1/orders') + check('all formal orders match the independent unified query', equalIds(workbench.rows.filter(r => r.mainStatus !== 'DRAFT'), formal.rows)) + const queues = workbench.first.queues + for (const name of ['all', 'fee', 'customs', 'draft']) { + check(`queue ${name} has a valid count`, Number.isSafeInteger(Number(queues?.[name])) && Number(queues[name]) >= 0) + } + check('all queue count matches complete rows', Number(queues.all) === workbench.rows.length) + check('fee count matches authoritative confirmation flags', Number(queues.fee) === workbench.rows.filter(r => r.customerFeeConfirmationRequired === true).length) + check('draft count matches draft rows', Number(queues.draft) === workbench.rows.filter(r => r.mainStatus === 'DRAFT').length) + for (const queue of ['fee', 'customs', 'draft']) { + const selected = await all(endpoint, true, { queue }) + check(`${queue} complete page count`, selected.rows.length === Number(queues[queue])) + const own = new Set(ids(workbench.rows)) + check(`${queue} contains only listed customer orders`, ids(selected.rows).every(id => own.has(id))) + if (queue === 'fee') check('fee queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.customerFeeConfirmationRequired === true))) + if (queue === 'draft') check('draft queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.mainStatus === 'DRAFT'))) + } + for (const [page, size] of [[21, 10], [2, 50]]) { + const result = await read(`${endpoint}?page=${page}&size=${size}`) + validatePage(result.page, page, size) + check(`page ${page} size ${size} preserves exact ordered slice`, JSON.stringify(ids(result.page.records)) + === JSON.stringify(ids(workbench.rows.slice((page - 1) * size, page * size)))) + } + const channels = workbench.first.channelNames + check('channel options include every listed channel', Array.isArray(channels) + && workbench.rows.every(r => !r.channelName || channels.includes(r.channelName))) + const channel = workbench.rows.find(r => r.channelName)?.channelName + requireFact(channel, 'A channel-filter sample is required') + const filtered = await all(endpoint, true, { channelName: channel }) + check('channel filter applies before counting and paging', equalIds(filtered.rows, workbench.rows.filter(r => r.channelName === channel))) + const spoofed = await read(`${endpoint}?page=1&size=200&tenantId=0&customerId=0`) + check('spoofed customer parameters cannot broaden scope', equalIds(spoofed.page.records, workbench.first.page.records) + && Number(spoofed.page.total) === workbench.rows.length) + const refreshed = await read(`${endpoint}?page=1&size=200`) + check('dataset stayed stable through verification', Number(refreshed.page.total) === workbench.rows.length + && JSON.stringify(ids(refreshed.page.records)) === JSON.stringify(ids(workbench.first.page.records))) + return { checks, total: workbench.rows.length, formalTotal: formal.rows.length, + membershipSha256: digest(workbench.rows), queues, + limitations: ['customs queue business membership requires separate customs evidence', 'GET evidence does not replace browser or second-customer authorization tests'] } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + const options = executionOptions(args, execArgs, env) + if (!options.execute) return { plan: true, businessWrites: false, checks: ['complete pagination', 'independent formal-order set', 'queue counts', 'page 21', 'channel filtering', 'customer parameter isolation'], required: 'Deploy Firstleg and Order workbench APIs first; use a stable customer dataset with more than 200 rows' } + const directory = resolve(root, 'reports', `client-workbench-${options.runId}`) + await mkdir(directory) // Exclusive run: existing evidence is never replaced by a rerun. + const report = { startedAt: new Date().toISOString(), readOnlyBusiness: true, success: false, requests: [], sessionClosed: false } + const save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(report, null, 2)) + let token = '', refresh = '' + async function request(method, path, body) { + requireFact(method === 'GET' || method === 'POST' && ['/auth/login', '/auth/logout'].includes(path), 'Business writes prohibited') + const query = new URLSearchParams(path.split('?')[1] || '') + const entry = { method, path: path.split('?')[0], outcome: 'PENDING', + page: query.get('page') || undefined, size: query.get('size') || undefined, queue: query.get('queue') || undefined, + queryKeys: [...query.keys()], querySha256: createHash('sha256').update(query.toString()).digest('hex') } + report.requests.push(entry); await save() + try { + const response = await fetch(options.base + path, { method, redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined }) + const payload = await response.json() + Object.assign(entry, { outcome: 'RECEIVED', http: response.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId }) + await save() + const safeCode = /^[A-Z0-9]{1,16}$/.test(String(payload.code)) ? String(payload.code) : 'INVALID_CODE' + requireFact(response.ok && String(payload.code) === '200', `API rejected ${entry.path}: HTTP ${response.status}, code ${safeCode}`) + return payload.data + } catch (error) { if (entry.outcome === 'PENDING') entry.outcome = 'UNKNOWN'; await save(); throw error } + } + try { + const login = await request('POST', '/auth/login', { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }) + token = login?.accessToken || ''; refresh = login?.refreshToken || '' + requireFact(token && refresh, 'Incomplete authentication response') + const profile = await request('GET', '/merchant/v1/iam/tenant/profile') + requireFact(profile?.id != null, 'Missing authenticated customer profile') + Object.assign(report, await verifyWorkbench(path => request('GET', path), profile.id)) + report.success = true + } catch (error) { + report.success = false + // Fixed verifier/HTTP classifications only; never persist an arbitrary response body or credential. + report.failure = error instanceof VerificationError ? error.message : 'Transport or response format failure' + } finally { + if (token) { + try { await request('POST', '/auth/logout', { refreshToken: refresh }); report.sessionClosed = true } + catch { report.success = false; report.logoutFailure = true } + } + report.success = report.success && report.sessionClosed + report.completedAt = new Date().toISOString(); await save() + } + return { success: report.success, sessionClosed: report.sessionClosed, report: resolve(directory, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Workbench verification did not start; check explicit TEST profile and new run-id.'); process.exitCode = 1 }) +} diff --git a/scripts/client-workbench-test-env.test.mjs b/scripts/client-workbench-test-env.test.mjs new file mode 100644 index 000000000..6e488a1f1 --- /dev/null +++ b/scripts/client-workbench-test-env.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { executionOptions, validatePage, verifyWorkbench } from './client-workbench-test-env.mjs' + +const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/api', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' } +const args = ['--execute', '--environment=TEST', '--run-id=fixture'] +const execArgs = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] +test('plan has no credential or environment dependency', () => { + assert.deepEqual(executionOptions([], [], {}), { execute: false }) + assert.deepEqual(executionOptions(['--plan'], [], {}), { execute: false }) +}) +test('execution requires explicit TEST profile, safe run id and API prefix', () => { + assert.equal(executionOptions(args, execArgs, env).runId, 'fixture') + for (const [a, x, e] of [ + [args, [], env], [args, execArgs, { ...env, CHONGCHONG_ENV: 'PROD' }], + [args, [execArgs[0].replace('test-acceptance', 'prod-acceptance')], env], + [['--execute', '--environment=TEST', '--run-id=../escape'], execArgs, env], + [[...args, '--resume'], execArgs, env], + [args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/' }], + ]) assert.throws(() => executionOptions(a, x, e)) +}) +function fixture() { + const rows = Array.from({ length: 215 }, (_, i) => ({ orderId: String(1000 + i), tenantId: '11', + mainStatus: i < 3 ? 'DRAFT' : 'LOADED', customerFeeConfirmationRequired: i >= 10 && i < 12, + channelName: i % 2 ? 'SEA' : 'AIR' })) + const calls = [] + const queues = list => ({ all: list.length, fee: list.filter(r => r.customerFeeConfirmationRequired).length, + customs: list.filter(r => r.orderId === '1012').length, draft: list.filter(r => r.mainStatus === 'DRAFT').length }) + function read(path) { + calls.push(path) + const url = new URL(path, 'https://test.invalid'), q = url.searchParams + let selected = rows.filter(r => !q.get('channelName') || r.channelName === q.get('channelName')) + const counts = queues(selected) + if (url.pathname === '/merchant/v1/orders') selected = selected.filter(r => r.mainStatus !== 'DRAFT') + if (q.get('queue') === 'fee') selected = selected.filter(r => r.customerFeeConfirmationRequired) + if (q.get('queue') === 'customs') selected = selected.filter(r => r.orderId === '1012') + if (q.get('queue') === 'draft') selected = selected.filter(r => r.mainStatus === 'DRAFT') + const number = Number(q.get('page')), size = Number(q.get('size')) + const page = { page: number, size, total: selected.length, pages: Math.ceil(selected.length / size), records: selected.slice((number - 1) * size, number * size).map(r => ({ ...r })) } + return url.pathname.endsWith('/workbench') ? { page, queues: counts, channelNames: ['AIR', 'SEA'] } : page + } + return { read, calls, rows } +} +test('complete traversal proves beyond-200 data, independent formal membership, queues and filters', async () => { + const h = fixture(), proof = await verifyWorkbench(h.read, '11') + assert.equal(proof.total, 215); assert.equal(proof.formalTotal, 212) + assert.ok(proof.checks.length >= 19); assert.equal(proof.membershipSha256.length, 64) + assert.ok(h.calls.includes('/merchant/v1/orders/workbench?page=21&size=10')) + assert.equal(proof.limitations.length, 2) +}) +for (const [name, mutate, expected] of [ + ['first-200 truncation', (r, path) => { if (path.includes('workbench?') && path.includes('page=1&size=200')) { r.page.total = 200; r.page.pages = 1 } }, /former 200/], + ['wrong page metadata', r => { (r.page ?? r).size = 10 }, /metadata/], + ['customer leakage', r => { (r.page ?? r).records[0].tenantId = '22' }, /Customer scope/], + ['duplicate identity', r => { const p = r.page ?? r; if (p.records.length > 1) p.records[1] = p.records[0] }, /duplicate/], + ['partial response', r => { (r.page ?? r).records.pop() }, /Truncated/], + ['wrong fee count', r => { if (r.queues) r.queues.fee = 99 }, /fee count/], + ['wrong draft count', r => { if (r.queues) r.queues.draft = 0 }, /draft count/], + ['missing channel option', r => { if (r.queues) r.channelNames = ['SEA'] }, /channel options/], + ['wrong page 21 slice', (r, path) => { if (path.includes('page=21&size=10')) r.page.records.reverse() }, /ordered slice/], +]) test(`refuses ${name}`, async () => { + const h = fixture() + await assert.rejects(verifyWorkbench(path => { const response = h.read(path); mutate(response, path); return response }, '11'), expected) +}) +test('changed independent formal set is a failure, not partial success', async () => { + const h = fixture() + await assert.rejects(verifyWorkbench(path => { + const result = h.read(path) + if (!path.includes('/workbench') && path.includes('page=1&') && result.records.length) result.records[0].orderId = 'foreign-formal' + return result + }, '11'), /independent unified/) +}) +test('empty out-of-range pages validate while impossible totals and missing ids fail', () => { + assert.equal(validatePage({ page: 3, size: 10, total: 20, pages: 2, records: [] }, 3, 10).total, 20) + assert.throws(() => validatePage({ page: 1, size: 10, total: -1, pages: 0, records: [] }, 1, 10)) + assert.throws(() => validatePage({ page: 1, size: 10, total: 1, pages: 1, records: [{}] }, 1, 10)) +})