Files
pj_h5_cloud/tests/file-upload-flow.test.cjs
2026-10-31 12:38:22 +08:00

301 lines
17 KiB
JavaScript

const { test } = require('node:test')
const assert = require('node:assert/strict')
const { createHmac } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const Module = require('node:module')
const ts = require('typescript')
const vue = require('vue')
const { parse } = require('vue/compiler-sfc')
const root = path.resolve(__dirname, '..')
const key = JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-upload.json'), 'utf8'))[0].key
const rawUrl = '/admin/sys-file/oss/file?fileName=photo.png'
const ok = { statusCode: 200, data: JSON.stringify({ code: 0, data: { url: rawUrl } }) }
const settle = async () => { await new Promise(setImmediate); await vue.nextTick() }
const compile = source => ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText
function runtime({ dev = true, apiBase = 'https://api.example/gateway/api', signingKey = key } = {}) {
const oldUni = global.uni, oldNow = Date.now, oldWarn = console.warn
const state = { now: 1793232000000, offset: 7200000, clockCalls: 0, offline: false,
token: { accessToken: 'first-token' }, intercept: undefined }
const requests = [], replies = [], warnings = [], toasts = [], files = []
Date.now = () => state.now
console.warn = (...args) => warnings.push(args)
global.uni = {
request(options) {
state.clockCalls++
queueMicrotask(() => state.offline ? options.fail({ errMsg: 'offline' }) : options.success({
statusCode: 200, data: { code: 0, data: { serverTime: String(state.now + state.offset), ttlSeconds: '1800' } },
}))
},
addInterceptor(name, value) { assert.equal(name, 'uploadFile'); state.intercept = value },
onNetworkStatusChange() {}, getStorageSync() { return state.token },
uploadFile(options) {
state.intercept?.invoke(options)
requests.push(options)
const reply = replies.shift() || ok
if (reply.throw) throw new Error('transport could not start')
queueMicrotask(() => {
if (reply.fail) options.fail?.(reply.fail)
else options.success?.(reply)
options.complete?.(reply.fail || reply)
})
return { abort() {} }
},
chooseImage(options) { options.success({ tempFiles: files }) },
chooseVideo(options) { options.success({ tempFilePath: files[0].path, size: files[0].size }) },
showToast(options) { toasts.push(options.title) }, showLoading() {}, hideLoading() {},
}
const env = { VITE_FILE_ACCESS_APP_ID: 'h5', VITE_FILE_ACCESS_KEY_ID: 'v1', VITE_FILE_ACCESS_KEY: signingKey,
VITE_API_URL: '/api', VITE_APP_DEV_SERVICE: 'https://dev.example', VITE_IMAGE_DEV_PATH: 'https://dev.example',
VITE_APP_PROD_SERVICE: apiBase, VITE_APP_PROD_IMAGE: 'https://images.example/api' }
function load(relative, stubs = {}) {
const filename = path.join(root, relative), loaded = new Module(filename, module)
loaded.filename = filename; loaded.paths = Module._nodeModulePaths(path.dirname(filename))
const originalRequire = loaded.require.bind(loaded)
loaded.require = name => name in stubs ? stubs[name] : originalRequire(name)
loaded._compile(compile(fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')')), filename)
return loaded.exports
}
const core = load('src/sheep/utils/file-access-core.ts')
const access = load('src/sheep/utils/file-access.ts', { './file-access-core': core, './app': { isDevEnv: () => dev } })
const hooks = load('src/sheep/hooks/use-token.ts', { '../constant/storage-constant': { default: { TOKEN: 'test-token' } },
'../router/index': {}, '../utils/app': { isDevEnv: () => dev } })
const flow = load('src/sheep/utils/file-upload.ts', { './file-access': access, '../hooks/use-token': hooks })
access.installFileAccess({ config: { globalProperties: {} }, mixin() {} })
const calls = options => new Promise(resolve => {
const result = { successes: [], failures: [], completes: [] }
flow.uploadFile({ url: '/api/admin/sys-file/upload', filePath: 'local.png', name: 'file', ...options,
success: value => result.successes.push(value), fail: value => result.failures.push(value),
complete: value => { result.completes.push(value); resolve(result) },
})
})
return { state, requests, replies, warnings, toasts, files, access, flow, core, hooks, calls,
close() { access.stopFileAccess(); global.uni = oldUni; Date.now = oldNow; console.warn = oldWarn } }
}
function verifySignature(request) {
const h = request.header
assert.equal(h['X-File-App-Id'], 'h5')
assert.equal(h['X-File-Key-Id'], 'v1')
assert.match(h['X-File-Nonce'], /^[A-Za-z0-9_-]{16,64}$/)
const canonical = ['v1', 'oss-file-upload', 'POST', 'upload', 'h5', 'v1', h['X-File-Timestamp'], h['X-File-Nonce']].join('\n')
assert.equal(h['X-File-Signature'], createHmac('sha256', Buffer.from(key, 'base64')).update(canonical).digest('hex'))
}
test('each upload calibrates time, reads current login and keeps multipart fields without duplicate headers', async () => {
const env = runtime()
try {
const options = { filePath: 'blob:photo', formData: { dir: 'refund', groupId: 2 },
header: { authorization: 'stale', 'tenant-id': '42', 'X-Custom': 'kept', 'content-type': 'application/json', 'x-file-nonce': 'stale' } }
const first = await env.calls(options)
assert.equal(first.successes.length, 1); assert.equal(first.completes.length, 1)
const request = env.requests[0]
verifySignature(request)
assert.equal(request.url, '/api/admin/sys-file/upload')
assert.equal(request.filePath, options.filePath); assert.deepEqual(request.formData, options.formData)
assert.equal(request.header.Authorization, 'Bearer first-token')
assert.equal(request.header['tenant-id'], '42'); assert.equal(request.header['TENANT-ID'], undefined)
assert.equal(request.header['CLIENT-TOC'], 'Y'); assert.equal(request.header['X-Custom'], 'kept')
assert.equal(request.header['content-type'], undefined); assert.equal(request.header['x-file-nonce'], undefined)
assert.equal(Number(request.header['X-File-Timestamp']), (env.state.now + env.state.offset) / 1000)
assert.equal(options.header.authorization, 'stale')
env.state.now += 40 * 60000; env.state.token = { access_token: { tokenValue: 'renewed-token' } }
await env.calls(options)
verifySignature(env.requests[1])
assert.equal(env.requests[1].header.Authorization, 'Bearer renewed-token')
assert.notEqual(request.header['X-File-Nonce'], env.requests[1].header['X-File-Nonce'])
assert.equal(env.state.clockCalls, 2)
} finally { env.close() }
})
test('production uploads use the configured API host and prefix, preserving third-party upload options', async () => {
const env = runtime({ dev: false })
try {
for (const url of ['/api/admin/sys-file/upload', 'admin/sys-file/upload', '/sys-file/upload',
'/gateway/api/admin/sys-file/upload', 'https://api.example/gateway/api/admin/sys-file/upload']) {
await env.calls({ url })
assert.equal(env.requests.at(-1).url, 'https://api.example/gateway/api/admin/sys-file/upload')
verifySignature(env.requests.at(-1))
}
const owned = env.access.prepareFileUpload({ url: '/api/admin/sys-file/upload?dir=a%2Bb#x' })
assert.equal(owned.url, 'https://api.example/gateway/api/admin/sys-file/upload?dir=a%2Bb#x')
assert.equal(env.access.prepareFileUpload(owned).url, owned.url)
for (const url of ['https://outside.example/admin/sys-file/upload', '//outside.example/admin/sys-file/upload',
'https://api.example.evil/admin/sys-file/upload', '/admin/user/import']) {
const headers = { Authorization: 'outside-auth', 'Content-Type': 'custom' }
await env.calls({ url, header: headers })
const request = env.requests.at(-1)
assert.equal(request.url, url); assert.deepEqual(request.header, headers)
assert.equal(request.header['X-File-Signature'], undefined)
}
} finally { env.close() }
})
for (const reason of ['upload_clock_skew', 'upload_signature_expired']) test(reason + ': retry once with a fresh nonce after clock sync', async () => {
const env = runtime()
try {
const denied = { statusCode: 403, data: JSON.stringify({ code: 403, reason }) }
env.replies.push(denied, ok)
const result = await env.calls()
assert.equal(env.requests.length, 2); assert.equal(env.state.clockCalls, 2)
env.requests.forEach(verifySignature)
assert.notEqual(env.requests[0].header['X-File-Nonce'], env.requests[1].header['X-File-Nonce'])
assert.equal(result.successes.length, 1); assert.equal(result.failures.length, 0); assert.equal(result.completes.length, 1)
env.replies.push(denied, denied)
const failed = await env.calls()
assert.equal(env.requests.length, 4); assert.equal(failed.failures.length, 1); assert.equal(failed.completes.length, 1)
} finally { env.close() }
})
test('HTTP/business/format/network errors fail exactly once and never retry possible completed writes', async () => {
const env = runtime()
try {
for (const reply of [
{ statusCode: 403, data: JSON.stringify({ reason: 'unknown_upload_key' }) },
{ statusCode: 403, data: JSON.stringify({ reason: 'upload_signature_replayed' }) },
{ statusCode: 404, data: '<html>not found</html>' },
{ statusCode: 415, data: JSON.stringify({ code: 415, msg: '文件类型不支持' }) },
{ statusCode: 200, data: JSON.stringify({ code: 1, msg: '存储失败' }) },
{ statusCode: 200, data: '{broken' },
{ statusCode: 200, data: JSON.stringify({ code: 0, data: {} }) },
{ statusCode: 200, data: JSON.stringify({ code: 1, reason: 'upload_clock_skew' }) },
{ fail: { errMsg: 'uploadFile:fail timeout' } }, { throw: true },
]) {
const before = env.requests.length
env.replies.push(reply)
const result = await env.calls()
assert.equal(env.requests.length, before + 1)
assert.equal(result.successes.length, 0); assert.equal(result.failures.length, 1); assert.equal(result.completes.length, 1)
}
assert.equal(JSON.stringify(env.warnings).includes(key), false)
} finally { env.close() }
})
test('offline clock sync retains signing and a bad key stops before sending a file', async () => {
let env = runtime()
try {
env.state.offline = true
await env.calls(); verifySignature(env.requests[0])
assert.equal(Number(env.requests[0].header['X-File-Timestamp']), env.state.now / 1000)
} finally { env.close() }
env = runtime({ signingKey: '' })
try {
const result = await env.calls()
assert.equal(env.requests.length, 0); assert.equal(result.failures.length, 1)
assert.match(result.failures[0].errMsg, /凭证/)
} finally { env.close() }
})
test('only successful uploads or edit echoes supply a stable URL', () => {
const env = runtime()
try {
const signed = env.access.fileUrl(rawUrl)
assert.equal(env.flow.uploadedFileUrl({ status: 2, response: JSON.stringify({ code: 0, data: { url: signed } }) }), env.access.originalFileUrl(signed))
assert.equal(env.flow.uploadedFileUrl({ response: { data: { url: rawUrl } } }), rawUrl)
for (const item of [null, { status: 1, response: ok.data }, { status: 3, response: ok.data },
{ response: 'uploadFile:fail timeout' }, { response: '{broken' }, { response: { code: 403, data: { url: rawUrl } } }]) {
assert.equal(env.flow.uploadedFileUrl(item), '')
}
} finally { env.close() }
})
// Execute each real script-setup. Uni's conditional compilation selects H5 branches here too.
function sfc(env, relative, names, stubs = {}, propValues = {}) {
const descriptor = parse(fs.readFileSync(path.join(root, relative), 'utf8')).descriptor
let enabled = true
const stack = []
const source = descriptor.scriptSetup.content.split('\n').filter(line => {
const start = /\/\/\s*#(ifdef|ifndef)\s+(\S+)/.exec(line)
if (start) { stack.push(enabled); enabled = enabled && (start[1] === 'ifdef' ? start[2] === 'H5' : start[2] !== 'H5'); return false }
if (/\/\/\s*#endif/.test(line)) { enabled = stack.pop(); return false }
return enabled
}).join('\n')
const props = vue.reactive({ ...propValues }), events = [], scope = vue.effectScope(), loaded = { exports: {} }
const dependencies = {
vue: { ...vue, onMounted() {} }, '@dcloudio/uni-app': { onLoad() {}, onShow() {}, onReachBottom() {}, onPullDownRefresh() {} },
'@/sheep/utils/file-upload': env.flow, '@@/utils/file-upload': env.flow,
'@/sheep/utils/file-access': env.access, '@@/index': { hooks: env.hooks, router: {} },
'../../libs/tool': { covetUniNumber: String }, '../../libs/config': { useTmConfig: () => ({ config: {} }) },
'../../interface': { TMUPLOAD_PHOTO_STATUS: { NOT: 0, UPLOADING: 1, UPLOAD_SUCCESS: 2, UPLOAD_FAILED: 3, EXCEEDS_SIZE_LIMIT: 5 } },
...stubs,
}
const defineProps = schema => {
for (const [name, option] of Object.entries(schema || {})) if (!(name in props)) {
props[name] = typeof option.default === 'function' ? option.default() : option.default
}
return props
}
const defineEmits = () => (name, value) => {
events.push({ name, value })
if (name === 'update:modelValue') props.modelValue = value
}
scope.run(() => new Function('require', 'exports', 'module', 'defineProps', 'defineEmits', 'defineOptions', 'defineExpose', 'withDefaults',
compile(source.replace(/import\.meta\.env/g, '({})') + '\nexport { ' + names.join(', ') + ' }'))(
name => name in dependencies ? dependencies[name] : require(name), loaded.exports, loaded,
defineProps, defineEmits, () => {}, () => {}, (props, defaults) => Object.assign(props, defaults, propValues)))
return { ...loaded.exports, props, events, close: () => scope.stop() }
}
for (const kind of ['photo', 'video']) test('actual ' + kind + ' component signs selections, exposes pending state and does not turn failures into success', async () => {
const env = runtime()
const component = sfc(env, `src/uni_modules/tm-ui/components/tm-upload-${kind}/tm-upload-${kind}.vue`, ['choose', 'startUpload', 'list', 'uploading'], {},
{ url: '/api/admin/sys-file/upload', header: { Authorization: 'stale' } })
try {
env.files.push({ path: 'blob:one.png', size: 42 }, { path: 'blob:two.png', size: 42 })
env.replies.push({ statusCode: 403, data: JSON.stringify({ reason: 'unknown_upload_key' }) }, ok)
component.choose()
assert.ok(component.events.some(event => event.name === 'update:modelValue' && event.value[0].status === 1))
await settle()
env.requests.forEach(verifySignature)
assert.equal(component.list.value[0].status, 3)
assert.equal(component.uploading.value, false)
if (kind === 'photo') {
assert.equal(component.list.value[1].status, 2)
assert.notEqual(env.requests[0].header['X-File-Nonce'], env.requests[1].header['X-File-Nonce'])
assert.equal(env.flow.uploadedFileUrl(component.list.value[1]), rawUrl)
}
const restored = sfc(env, `src/uni_modules/tm-ui/components/tm-upload-${kind}/tm-upload-${kind}.vue`, ['list'], {},
{ modelValue: component.props.modelValue })
try { assert.equal(restored.list.value[0].status, 3) } finally { restored.close() }
component.startUpload(); await settle()
assert.equal(component.list.value[0].status, 2)
verifySignature(env.requests.at(-1))
} finally { component.close(); env.close() }
})
test('actual rich text uploader sends credentials and retains unsigned references; failed responses do not open insertion dialog', async () => {
const env = runtime()
const editor = sfc(env, 'src/sheep/components/rich-text-editor/rich-text-editor.vue', ['uploadImage', 'pendingImageUrl', 'showImageModal'])
try {
editor.uploadImage('blob:richtext'); await settle()
verifySignature(env.requests[0]); assert.equal(editor.pendingImageUrl.value, rawUrl); assert.equal(editor.showImageModal.value, true)
editor.showImageModal.value = false
env.replies.push({ statusCode: 403, data: JSON.stringify({ msg: '上传身份校验失败' }) })
editor.uploadImage('blob:invalid'); await settle()
assert.equal(editor.showImageModal.value, false)
assert.ok(env.toasts.includes('上传身份校验失败'))
} finally { editor.close(); env.close() }
})
test('actual refund form handles failed uploads safely and submits only stable successful references', async () => {
const env = runtime(), submitted = []
const page = sfc(env, 'src/pages/refund/apply.vue', ['uploadApplyPicUrls', 'applyPicUrls', 'applyForm', 'doSubmit'], {
'@@/api': { refundApi: { create: async body => { submitted.push(body); return { code: 0 } } } },
'@@/utils/file': { filePath: env.access.fileUrl }, '@@/index': { hooks: env.hooks, router: { jump() {} } },
})
try {
page.applyForm.reasonStr = '测试原因'; page.applyForm.remark = '测试描述'
const success = { status: 2, response: ok.data }, failure = { status: 3, response: '上传身份校验失败' }
page.uploadApplyPicUrls.value = [success, failure]
assert.equal(page.applyPicUrls.value, rawUrl)
await page.doSubmit(); assert.equal(submitted.length, 0)
page.uploadApplyPicUrls.value = [success, { status: 1, response: null }]
await page.doSubmit(); assert.equal(submitted.length, 0)
page.uploadApplyPicUrls.value = [success]
await page.doSubmit(); assert.equal(submitted.length, 1); assert.equal(submitted[0].applyPicUrls, rawUrl)
} finally { page.close(); env.close() }
})