301 lines
17 KiB
JavaScript
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() }
|
|
})
|