test(order): add readonly merchant workbench pagination acceptance

This commit is contained in:
2026-10-03 15:46:42 +08:00
parent dd52bfd043
commit bf8a985f22
3 changed files with 233 additions and 0 deletions
+154
View File
@@ -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 })
}