155 lines
11 KiB
JavaScript
155 lines
11 KiB
JavaScript
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 === '/' && !base.search && !base.hash, 'Expected the configured TEST Gateway origin')
|
|
return { execute: true, runId: args[2].slice(9), base: base.origin + '/api' }
|
|
}
|
|
|
|
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 })
|
|
}
|