feat:日志敏感信息过滤
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s

This commit is contained in:
dzc committed 2026-10-29 15:46:24 +08:00
1 parent 3e744b63f1
commit 1fa2f11057
10 files changed
+362 -14

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
const {test} = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const Module = require('node:module')
const ts = require('typescript')
const root = path.resolve(__dirname, '..')
const folder = fs.existsSync(path.join(root,'src/utils/file-access-core.ts')) ? 'src/utils' : 'src/sheep/utils'
const corePath = path.join(root,folder,'file-access-core.ts')
const compile = source => ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText
const core = new Module(corePath,module); core.filename=corePath; core.paths=Module._nodeModulePaths(path.dirname(corePath))
core._compile(compile(fs.readFileSync(corePath,'utf8')),corePath)
const vectors=JSON.parse(fs.readFileSync(path.join(root,'test-vectors/file-upload.json'),'utf8'))
for(const v of vectors) test('upload signature vector: '+v.target,()=>{
assert.equal(core.exports.fileUploadCanonical(v.target,v.appId,v.kid,v.ts,v.nonce),v.canonical)
assert.equal(core.exports.fileUploadSignature(v.target,v.appId,v.kid,v.ts,v.nonce,v.key),v.sign)
})
test('upload credentials bind the purpose and target, renew per request and never leak to other hosts or imports',()=>{
let time=1793232000000
const client=core.exports.createFileAccess({appId:'admin',kid:'v1',key:vectors[0].key,baseUrl:()=> 'https://files.example/gateway/api',now:()=>time,allowedOrigins:['https://legacy.example']})
for(const [source,target] of [['/api/admin/sys-file/upload','upload'],['/sys-file/inner-upload','inner-upload'],['https://legacy.example/admin/sys-file/qn/upload','qn-upload'],['https://files.example/gateway/api/admin/sys-file/upload','upload']]){
const headers=client.uploadHeaders(source,vectors[0].nonce)
assert.equal(headers['X-File-Signature'],core.exports.fileUploadSignature(target,'admin','v1',1793232000,vectors[0].nonce,vectors[0].key))
assert.equal(Object.values(headers).includes(vectors[0].key),false)
}
const first=client.uploadHeaders('/admin/sys-file/upload'), second=client.uploadHeaders('/admin/sys-file/upload')
assert.notEqual(first['X-File-Nonce'],second['X-File-Nonce'])
assert.notEqual(first['X-File-Signature'],second['X-File-Signature'])
assert.equal(first['X-File-Timestamp'],'1793232000')
client.synchronize(time+86400000,1800,0)
assert.equal(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],'1793318400')
for(const source of ['https://outside.example/admin/sys-file/upload','https://files.example.evil/admin/sys-file/upload','/admin/sys-file/oss/file?fileName=x','/admin/user/import','data:upload','blob:upload']){
assert.deepEqual(client.uploadHeaders(source),{})
}
assert.notEqual(core.exports.fileAccessSignature('a.jpg','admin','v1',1793232000,vectors[0].key),first['X-File-Signature'])
})
function runtime(){
const filename=path.join(root,folder,'file-access.ts')
const env={VITE_FILE_ACCESS_APP_ID:'admin',VITE_FILE_ACCESS_KEY_ID:'v1',VITE_FILE_ACCESS_KEY:vectors[0].key,VITE_IMAGE_BASE_PATH:'https://files.example/api',VITE_IMAGE_DEV_PATH:'https://files.example/api',VITE_APP_DEV_SERVICE:'https://files.example/api',VITE_APP_PROD_IMAGE:'https://files.example/api',VITE_APP_PROD_SERVICE:'https://files.example/api'}
const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
loaded.require=name=>name==='./file-access-core'?core.exports:name==='./app'?{isDevEnv:()=>true}:core.require(name)
loaded._compile(compile(fs.readFileSync(filename,'utf8').replace(/import\.meta\.env/g,'('+JSON.stringify(env)+')')),filename)
return loaded.exports
}
if(folder==='src/utils') test('the real Axios interceptor signs each file upload and preserves login and tenant headers',()=>{
const api=runtime();let onRequest
const axios={getUri:require('axios').getUri,create:()=>({interceptors:{request:{use(fn){onRequest=fn}},response:{use(){}}}})}
const filename=path.join(root,'src/utils/request.ts');const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
loaded.require=name=>{
if(name==='axios')return {default:axios}
if(name==='./file-access')return api
if(name==='./other')return {default:{adaptationUrl:url=>url}}
if(name==='/@/utils/storage')return {Session:{getToken:()=> 'user-token',getTenant:()=> '42'}}
if(name==='/@/hooks/message')return {}
return core.require(name)
}
loaded._compile(compile(fs.readFileSync(filename,'utf8').replace(/import\.meta\.env/g,'({VITE_API_URL:"/api"})')),filename)
const first=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
const second=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
assert.equal(first.headers.Authorization,'Bearer user-token');assert.equal(first.headers['TENANT-ID'],'42')
assert.ok(first.headers['X-File-Signature']);assert.notEqual(first.headers['X-File-Nonce'],second.headers['X-File-Nonce'])
assert.deepEqual(first.data,{file:'unchanged'})
const other=onRequest({method:'post',url:'https://outside.example/admin/sys-file/upload',headers:{}})
assert.equal(other.headers['X-File-Signature'],undefined)
const otherBase=onRequest({method:'post',baseURL:'https://outside.example/api',url:'/admin/sys-file/upload',headers:{}})
assert.equal(otherBase.headers['X-File-Signature'],undefined)
})
else test('the actual uni upload interceptor signs photo, video and rich text calls without changing form data',()=>{
const previous=global.uni;let interceptor
global.uni={addInterceptor(name,hook){assert.equal(name,'uploadFile');interceptor=hook},onNetworkStatusChange(){}}
try{
const api=runtime();api.installFileAccess({config:{globalProperties:{}},mixin(){}})
const first={url:'/api/admin/sys-file/upload',header:{Authorization:'Bearer user','TENANT-ID':'42'},formData:{dir:'photos'},filePath:'local-photo.png'}
const second={...first,header:{...first.header}}
interceptor.invoke(first);interceptor.invoke(second)
assert.ok(first.header['X-File-Signature']);assert.notEqual(first.header['X-File-Nonce'],second.header['X-File-Nonce'])
assert.equal(first.header.Authorization,'Bearer user');assert.equal(first.header['TENANT-ID'],'42')
assert.deepEqual(first.formData,{dir:'photos'});assert.equal(first.filePath,'local-photo.png')
const other={url:'https://outside.example/upload',header:{Authorization:'other'}};interceptor.invoke(other)
assert.deepEqual(other.header,{Authorization:'other'})
}finally{global.uni=previous}
})
+58 -6
View File
@@ -36,6 +36,8 @@ function setup() {
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: "log.vue", id: "log" }).errors, [])
const page = load(compileScript(descriptor, { id: "log" }).content, {
vue, "/@/api/admin/log": api,
dayjs: { default: require("dayjs") },
"dayjs/plugin/customParseFormat": { default: require("dayjs/plugin/customParseFormat") },
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
"vue-i18n": { useI18n: () => ({ t: key => key }) },
"/@/hooks/table": { useTable: () => ({ getDataList() {}, tableStyle: {} }) },
@@ -79,15 +81,65 @@ test("日志异步导出固定类型、时间和应用筛选,重复点击继
assert.deepEqual(downloads, [["/admin/sys-file/oss/file?fileName=logs.xlsx", "操作日志.xlsx"]])
})
test("未选择或清空日志时间时提交空数组,任务失败后可以重新导出", async () => {
test("未选择、清空或只选择一端时间时提示补全,不提交导出任务", async () => {
for (const range of ["", null, [], [null, null], ["2026-10-29 00:00:00"], ["2026-10-29 00:00:00", null]]) {
const { state, requests, errors, progress } = setup()
state.state.queryForm.createTime = range
state.showSearch.value = false
await state.exportExcel()
assert.equal(requests.length, 0)
assert.deepEqual(progress, [])
assert.deepEqual(errors, ["导出前请选择完整的日志创建时间范围"])
assert.equal(state.showSearch.value, true)
}
})
test("时间范围按自然月限制,跨月、月底和闰年边界与后端一致", async () => {
for (const range of [
["2026-01-31 08:30:00", "2026-02-28 08:30:00"],
["2024-01-31 08:30:00", "2024-02-29 08:30:00"],
["2026-10-01 00:00:00", "2026-11-01 00:00:00"],
["2026-10-29 00:00:00", "2026-10-29 00:00:00"],
]) {
const { state, requests, errors } = setup()
state.state.queryForm.createTime = range
await state.exportExcel()
assert.deepEqual(errors, [])
assert.equal(requests.length, 1)
assert.deepEqual(requests[0].data.createTime, range)
}
for (const range of [
["2026-01-31 08:30:00", "2026-02-28 08:30:01"],
["2024-01-31 08:30:00", "2024-02-29 08:30:01"],
["2026-10-01 00:00:00", "2026-11-01 00:00:01"],
]) {
const { state, requests, errors } = setup()
state.state.queryForm.createTime = range
await state.exportExcel()
assert.equal(requests.length, 0)
assert.deepEqual(errors, ["日志导出时间范围不能超过一个月"])
}
})
test("无效日期和倒序日期不提交,后台任务失败后可以修改条件重试", async () => {
const { state, requests, errors } = setup()
state.state.queryForm.createTime = ["2026-02-30 00:00:00", "2026-03-01 00:00:00"]
await state.exportExcel()
assert.deepEqual(requests[0].data, { logType: "", createTime: [], serviceId: "" })
assert.equal(state.state.queryForm.createTime, "")
state.handleExportFailure({ message: "当前筛选条件没有可导出的日志" })
assert.deepEqual(errors, ["当前筛选条件没有可导出的日志"])
assert.equal(requests.length, 0)
assert.equal(errors.at(-1), "请选择有效的日志创建时间范围")
state.state.queryForm.createTime = ["2026-10-30 00:00:00", "2026-10-29 00:00:00"]
await state.exportExcel()
assert.equal(requests.length, 0)
assert.equal(errors.at(-1), "日志创建时间的开始时间不能晚于结束时间")
state.state.queryForm.createTime = ["2026-10-29 00:00:00", "2026-10-30 00:00:00"]
await state.exportExcel()
assert.equal(requests.length, 1)
state.state.queryForm.createTime = null
await state.exportExcel()
assert.equal(requests.length, 1, "执行中的任务继续查看进度,不受新筛选条件影响")
state.handleExportFailure({ message: "当前筛选条件没有可导出的日志" })
assert.equal(errors.at(-1), "当前筛选条件没有可导出的日志")
state.state.queryForm.createTime = ["2026-10-28 00:00:00", "2026-10-29 00:00:00"]
await state.exportExcel()
assert.equal(requests.length, 2)
assert.deepEqual(requests[1].data.createTime, [])
})
+87
View File
@@ -0,0 +1,87 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vue = require('vue')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const { compile, root, runtime } = require('./helpers/file-access-runtime.cjs')
function setup(relative, props, request, fileAccess = {}) {
const descriptor = parse(fs.readFileSync(path.join(root, relative), 'utf8')).descriptor
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: relative, id: 'upload-transport' }).errors, [])
const errors = [], events = [], loaded = { exports: {} }
const vueHooks = { ...vue, onBeforeUnmount() {} }
const mocks = {
vue: vueHooks,
'/@/utils/request': { default: request },
'/@/utils/storage': { Session: { get: () => 'token', getTenant: () => '42' } },
'/@/hooks/message': { useMessage: () => ({ error: value => errors.push(value) }) },
'/@/utils/other': { default: { adaptationUrl: url => url, addUnit: value => value } },
'/@/utils/file-access': fileAccess,
'/@/utils/file-access-dom': { bindFileAccessDom() {} },
'vue-i18n': { useI18n: () => ({ t: key => key }) },
'@wangeditor/editor/dist/css/style.css': {},
'@wangeditor/editor-for-vue': { Toolbar: {}, Editor: {} },
}
const globals = { ref: vue.ref, computed: vue.computed, watch: vue.watch,
getCurrentInstance: () => ({ proxy: { baseURL: '/api' } }), onMounted() {}, nextTick: vue.nextTick }
new Function('module', 'exports', 'require', ...Object.keys(globals), compile(compileScript(descriptor, { id: 'upload-transport' }).content))(
loaded, loaded.exports, name => name in mocks ? mocks[name] : require(name), ...Object.values(globals))
const state = loaded.exports.default.setup(props, { expose() {}, emit: (...args) => events.push(args) })
return { state, errors, events }
}
test('generic uploads preserve multipart fields, delegate URL adaptation to the common transport, and finish a batch after one file fails', async () => {
const requests = []
const file = new File(['%PDF-1.4\n%%EOF'], 'report.PDF', { type: 'application/pdf' })
for (const type of ['default', 'simple']) {
const { state, errors, events } = setup('src/components/Upload/index.vue', {
type, modelValue: '', fileType: ['pdf'], fileSize: 100, data: {}, dir: 'reports', uploadFileUrl: '/admin/sys-file/upload',
}, async options => { requests.push(options); return { code: 0, data: { url: '/admin/sys-file/oss/file?fileName=report.pdf' } } })
assert.equal(state.handleBeforeUpload(file), true)
assert.equal(state.handleBeforeUpload(file), true)
const result = await state.handleHttpUpload({ action: '/api/admin/sys-file/upload', filename: 'file', file,
data: { dir: 'reports', groupId: 12, type: 'pdf' } })
const request = requests.at(-1)
assert.equal(request.url, '/admin/sys-file/upload')
assert.equal(request.baseURL, undefined)
assert.equal(request.data.get('file').name, 'report.PDF')
assert.equal(request.data.get('dir'), 'reports')
assert.equal(request.data.get('groupId'), '12')
assert.equal(request.data.get('type'), 'pdf')
state.handleUploadSuccess(result, { name: file.name, raw: file, size: file.size })
assert.equal(events.length, 0)
state.handleUploadError({ msg: '不支持上传此文件类型' })
assert.deepEqual(errors, ['不支持上传此文件类型'])
assert.equal(state.number.value, 0)
assert.equal(events.find(event => event[0] === 'update:modelValue')[1], '/admin/sys-file/oss/file?fileName=report.pdf')
}
})
test('rich text images and videos use the common transport, preserve raw saved references, and propagate upload failures', async () => {
const env = runtime(), requests = [], inserted = []
const raw = '/admin/sys-file/oss/file?fileName=photo.png'
let result = { code: 0, data: { url: raw } }
let transportError
try {
const { state, events } = setup('src/components/Editor/index.vue', {
getHtml: '', placeholder: '', height: '310', width: 'auto', uploadFileUrl: '/admin/sys-file/upload',
}, async options => { requests.push(options); if (transportError) throw transportError; return result }, env.api)
for (const name of ['uploadImage', 'uploadVideo']) {
const file = new File(['test content'], name === 'uploadImage' ? 'photo.png' : 'video.mp4')
await state.state.editorConfig.MENU_CONF[name].customUpload(file, value => inserted.push(value))
assert.equal(requests.at(-1).url, '/admin/sys-file/upload')
assert.equal(requests.at(-1).method, 'post')
assert.equal(requests.at(-1).data.get('file').name, file.name)
}
state.handleChange({ getHtml: () => '<img src="' + env.api.fileUrl(raw) + '">', getText: () => '' })
const saved = events.find(event => event[0] === 'update:getHtml')[1]
assert.doesNotMatch(saved, /[?&](?:sign|appId|kid|ts)=/)
assert.match(saved, /fileName=photo\.png/)
result = { code: 1, msg: '文件内容与后缀类型不符' }
await assert.rejects(state.uploadAttr.customUpload(new File(['bad'], 'bad.png'), value => inserted.push(value)), /文件内容与后缀类型不符/)
transportError = { msg: '上传身份校验失败' }
await assert.rejects(state.uploadAttr.customUpload(new File(['bad'], 'bad.png'), value => inserted.push(value)), /上传身份校验失败/)
assert.equal(inserted.length, 2)
} finally { env.close() }
})