fix(client): label delivery fees and verify receipt downloads

This commit is contained in:
2026-10-02 11:25:12 +08:00
parent 162e9a2336
commit c115f1c504
3 changed files with 256 additions and 1 deletions
@@ -0,0 +1,60 @@
import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { createHash } from 'node:crypto'
// Local-only verification. Expectations must come from this run's uploaded
// fixture bytes, not from the downloaded file or a previous Downloads entry.
export async function verifyReceiptFile(path, expected, startedAt) {
const metadata = await stat(path)
const bytes = await readFile(path)
const sha256 = createHash('sha256').update(bytes).digest('hex')
const checks = {
currentRun: metadata.mtimeMs >= startedAt,
fileName: basename(path) === expected.fileName,
size: bytes.length === expected.sizeBytes,
content: sha256 === expected.sha256,
}
return { passed: Object.values(checks).every(Boolean), checks, sizeBytes: bytes.length, sha256, savedFileName: basename(path) }
}
// Run only inside the approved computer-use session, passing its existing Tab.
// This module never connects to a browser, logs in, fetches HTTP or changes data.
// Root supplies fresh fixture metadata and logs in through the normal page UI.
// Example in cua_repl after reading the skill and selecting the customer tab:
// const { acceptReceiptDownload } = await import('<absolute path to this module>')
// const result = await acceptReceiptDownload(tab, expected)
// expected = { deliveryNo, fileName, sizeBytes, sha256 } from the source fixture.
// Save result with the current run ID/version; timeout is BLOCKED, not PASS.
export async function acceptReceiptDownload(tab, expected) {
if (!expected.deliveryNo || !expected.fileName || !Number.isSafeInteger(expected.sizeBytes)
|| expected.sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(expected.sha256)) {
throw new Error('Fresh fixture delivery number, filename, byte length and SHA-256 are required')
}
const snapshot = await tab.playwright.domSnapshot()
const heading = tab.playwright.getByRole('heading', { name: expected.deliveryNo, exact: true })
if (!(await heading.isVisible())) throw new Error('Open the exact fixture delivery detail first')
const row = tab.playwright.getByRole('row').filter({ hasText: expected.fileName })
if (await row.count() !== 1) throw new Error('Expected exactly one receipt row')
const startedAt = Date.now()
const [download, click] = await Promise.allSettled([
tab.playwright.waitForEvent('download', { timeoutMs: 20000 }).then(d => d.path({ timeoutMs: 10000 })),
row.getByRole('button', { name: '下载', exact: true }).click({ timeoutMs: 15000 }),
])
const base = { deliveryNo: expected.deliveryNo, fileName: expected.fileName, startedAt,
clickCompleted: click.status === 'fulfilled', receiptVisible: snapshot.includes(expected.fileName) }
if (click.status !== 'fulfilled' || download.status !== 'fulfilled' || !download.value) {
return { ...base, status: 'BLOCKED', reason: 'No completed browser save with a current local path', savedFileVerified: false }
}
const verification = await verifyReceiptFile(download.value, expected, startedAt)
return { ...base, status: verification.passed ? 'PASS' : 'FAIL', verification }
}
// Real isolation companion procedure (root-owned environment run):
// 1. A logs in, opens fresh A delivery, runs acceptReceiptDownload; retain result.
// 2. Log out via UI, log in as unrelated B, open the same A detail URL.
// 3. Require denied detail/no receipt filename/no download control and record DOM.
// 4. Root's HTTP suite must independently verify B->A binary endpoint rejection.
// A hidden button alone is not server isolation. Do not insert tokens into DOM,
// use raw CDP, reuse an old file, or replace missing UI save with HTTP success.
// If tool paths have opaque basenames, filename check remains FAIL/unproven;
// obtain browser download record plus original saved file through the user.
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import ts from 'typescript'
import axios from 'axios'
import { ref, computed } from 'vue'
import { mkdtemp, writeFile, utimes, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { verifyReceiptFile, acceptReceiptDownload } from './receiptBrowserAcceptance.mjs'
// Execute production HTTP, API and SFC setup code. Only transport, browser saving
// and UI feedback are replaced; this is not a browser/server isolation test.
function compile(path, dependencies, globals = {}) {
let source = readFileSync(new URL(path, import.meta.url), 'utf8')
if (path.endsWith('.vue')) {
source = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
+ '\nexport { downloadReceipt, loadReceipts, receiptFiles, downloadingFileId, receiptsError, feeColumns }'
}
source = source.replace('import.meta.env.VITE_API_BASE_URL', "'/api'")
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true,
} }).outputText
const exports = {}
new Function('require', 'exports', ...Object.keys(globals), code)((id) => {
assert.ok(id in dependencies, `Unexpected dependency ${id}`)
return dependencies[id]
}, exports, ...Object.values(globals))
return exports
}
const payload = Buffer.from([0, 255, 13, 10, 128, 1, 2, 3])
const file = { fileId: 'file/1', fileName: '签收证明 #1.bin', sizeBytes: payload.length }
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
test('delivery detail fee column renders formal fee names without changing unknown values', () => {
const { component } = harness()
const column = component.feeColumns.find(item => item.prop === 'feeType')
for (const [input, expected] of [
['DELIVERY_FEE', '派送费'], ['LOADING_FEE', '装车费'], ['APPOINTMENT_FEE', '预约费'],
['', '—'], [null, '—'], [undefined, '—'], ['OTHER_FEE', 'OTHER_FEE'], ['特殊服务费', '特殊服务费'],
]) assert.equal(column.formatter({}, {}, input), expected)
})
function harness({ token = 'customer-A', rejectDownload, rejectList, pending, clickError } = {}) {
const requests = [], saved = [], errors = [], revoked = [], urls = new Map()
const http = compile('../../api/http.ts', {
axios: { ...axios, create: config => axios.create({ ...config, adapter: async config => {
requests.push({ url: config.url, authorization: config.headers.get('Authorization'), responseType: config.responseType })
const binary = config.url.endsWith('/download')
const status = binary ? rejectDownload : rejectList
if (status) {
const response = { status, data: new Blob([JSON.stringify({ code: String(status), message: '无权访问签收文件' })], { type: 'application/json' }), headers: {}, config }
throw new axios.AxiosError('denied', 'ERR_BAD_REQUEST', config, null, response)
}
if (binary && pending) await pending
return { status: 200, headers: {}, config, data: binary
? new Blob([payload], { type: 'application/octet-stream' })
: { code: '200', message: '成功', data: { deliveryNo: 'DL/A', files: [file] } } }
} }) },
'@chongchong/shared': { parseApiJsonResponse: value => value },
'@/api/session': { getAccessToken: () => token, getRefreshToken: () => '', persistTokenPair() {}, clearSessionTokens() {} },
'@/api/errorDetails': compile('../../api/errorDetails.ts', {}),
'@/api/types': compile('../../api/types.ts', {}),
'@/api/crossTabRefresh': compile('../../api/crossTabRefresh.ts', {}),
})
const api = compile('./api/deliveryOrders.ts', { '@/api/http': http })
const component = compile('./views/DeliveryOrderDetailView.vue', {
vue: { ref, computed, onMounted() {} },
'vue-router': { useRouter: () => ({ push() {} }) },
'element-plus': { ElMessage: { error: message => errors.push(message) } },
dayjs: () => {}, '../api/deliveryOrders': api,
}, {
defineProps: () => ({ deliveryNo: 'DL/A' }),
URL: { createObjectURL(blob) { const url = `blob:${urls.size}`; urls.set(url, blob); return url }, revokeObjectURL(url) { revoked.push(url); urls.delete(url) } },
document: { createElement(tag) {
assert.equal(tag, 'a')
return { href: '', download: '', click() {
if (clickError) throw clickError
assert.ok(urls.has(this.href), 'blob must still exist when saving is requested')
saved.push({ fileName: this.download, blob: urls.get(this.href) })
} }
} },
})
return { component, requests, saved, errors, revoked, urls }
}
test('C63 real handler passes exact binary bytes and Unicode filename to browser save', async () => {
const h = harness()
await h.component.loadReceipts()
await h.component.downloadReceipt(file.fileId)
assert.equal(h.saved.length, 1)
assert.equal(h.saved[0].fileName, file.fileName)
const bytes = Buffer.from(await h.saved[0].blob.arrayBuffer())
assert.deepEqual(bytes, payload)
assert.equal(sha256(bytes), sha256(payload))
const changed = Buffer.from(payload); changed[0] ^= 1
assert.equal(changed.length, bytes.length)
assert.notEqual(sha256(bytes), sha256(changed), 'same size is not content evidence')
assert.deepEqual(h.requests.map(r => r.url), ['/merchant/v1/last-leg/delivery-orders/DL%2FA/documents/receipt', '/merchant/v1/last-leg/delivery-orders/DL%2FA/documents/receipt/files/file%2F1/download'])
assert.equal(h.requests[1].responseType, 'blob')
assert.equal(h.requests[1].authorization, 'Bearer customer-A')
assert.equal(h.component.downloadingFileId.value, '')
assert.equal(h.revoked.length, 1)
assert.equal(h.urls.size, 0)
assert.deepEqual(h.errors, [])
})
for (const status of [403, 404, 500]) {
test(`C63 HTTP ${status} never saves an error body and releases busy state`, async () => {
const h = harness({ rejectDownload: status })
await h.component.loadReceipts()
await h.component.downloadReceipt(file.fileId)
assert.equal(h.saved.length, 0)
assert.equal(h.urls.size, 0)
assert.equal(h.revoked.length, 0)
assert.equal(h.errors.length, 1)
assert.equal(h.component.downloadingFileId.value, '')
})
}
test('C63 unknown file and duplicate in-flight clicks do not issue another download', async () => {
let finish
const h = harness({ pending: new Promise(resolve => { finish = resolve }) })
await h.component.loadReceipts()
await h.component.downloadReceipt('not-in-receipt-list')
assert.equal(h.requests.length, 1)
const first = h.component.downloadReceipt(file.fileId)
await h.component.downloadReceipt(file.fileId)
finish()
await first
assert.equal(h.requests.length, 2)
assert.equal(h.saved.length, 1)
})
test('C63 denied customer list clears prior file choices; denied download uses current customer session', async () => {
const h = harness({ token: 'customer-B', rejectList: 403, rejectDownload: 403 })
h.component.receiptFiles.value = [file]
await h.component.loadReceipts()
assert.equal(h.component.receiptFiles.value.length, 0)
assert.ok(h.component.receiptsError.value)
await h.component.downloadReceipt(file.fileId)
assert.equal(h.requests.length, 1)
// Simulate a stale visible choice: the real API must still use B's session.
h.component.receiptFiles.value = [file]
await h.component.downloadReceipt(file.fileId)
assert.ok(h.requests.every(r => r.authorization === 'Bearer customer-B'))
assert.equal(h.saved.length, 0)
assert.equal(h.errors.length, 1)
})
test('C63 browser save exception releases object URL and permits retry', async () => {
const h = harness({ clickError: new Error('save failed') })
await h.component.loadReceipts()
await h.component.downloadReceipt(file.fileId)
assert.equal(h.saved.length, 0)
assert.deepEqual(h.errors, ['save failed'])
assert.equal(h.revoked.length, 1)
assert.equal(h.component.downloadingFileId.value, '')
await h.component.downloadReceipt(file.fileId)
assert.equal(h.requests.length, 3)
assert.equal(h.errors.length, 2)
})
test('C63 disk evidence rejects same-size corruption, stale files and wrong filenames', async t => {
const directory = await mkdtemp(join(tmpdir(), 'c63-download-'))
t.after(() => rm(directory, { recursive: true, force: true }))
const path = join(directory, file.fileName)
const expected = { fileName: file.fileName, sizeBytes: payload.length, sha256: sha256(payload) }
const startedAt = Date.now() - 1000
await writeFile(path, payload)
assert.equal((await verifyReceiptFile(path, expected, startedAt)).passed, true)
const changed = Buffer.from(payload); changed[0] ^= 1
await writeFile(path, changed)
const corrupted = await verifyReceiptFile(path, expected, startedAt)
assert.equal(corrupted.checks.size, true)
assert.equal(corrupted.checks.content, false)
assert.equal(corrupted.passed, false)
await writeFile(path, payload)
await utimes(path, new Date(0), new Date(0))
assert.equal((await verifyReceiptFile(path, expected, startedAt)).checks.currentRun, false)
const wrongPath = join(directory, 'wrong-name.bin')
await writeFile(wrongPath, payload)
assert.equal((await verifyReceiptFile(wrongPath, expected, startedAt)).checks.fileName, false)
})
test('C63 browser evidence refuses missing fixture hash before interacting', async () => {
await assert.rejects(acceptReceiptDownload({}, { deliveryNo: 'DL/A', fileName: file.fileName }), /SHA-256/)
})
@@ -36,7 +36,7 @@ const itemColumns: DataTableColumn[] = [
{ prop: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right', headerAlign: 'right', formatter: tableValueFormatter },
]
const feeColumns: DataTableColumn[] = [
{ prop: 'feeType', label: '费用类型', minWidth: 180, formatter: tableValueFormatter }, { prop: 'currency', label: '币种', width: 100, formatter: tableValueFormatter },
{ prop: 'feeType', label: '费用类型', minWidth: 180, formatter: (_row: object, _column: unknown, value: string | null | undefined) => formatDeliveryFeeType(value) }, { prop: 'currency', label: '币种', width: 100, formatter: tableValueFormatter },
{ prop: 'amount', label: '金额', width: 140, align: 'right', headerAlign: 'right', formatter: tableValueFormatter }, { prop: 'remark', label: '备注', minWidth: 240, formatter: tableValueFormatter },
]
@@ -50,6 +50,11 @@ function display(value: string | number | null | undefined) {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
function formatDeliveryFeeType(value: string | null | undefined) {
const labels: Record<string, string> = { DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '预约费' }
return value ? labels[value] ?? value : '—'
}
function formatDateTime(value: string | null | undefined) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—'
}