fix:图片访问
fix:权限修改
This commit is contained in:
1 parent
dec9fd96e3
commit
d86bc810cc
69 files changed
+1264
-458
No files matched your search
@@ -0,0 +1,67 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
const vue = require("vue")
|
||||
const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||
const root = path.join(__dirname, "../src")
|
||||
|
||||
function load(source, mocks, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 },
|
||||
}).outputText
|
||||
const mod = { exports: {} }
|
||||
new Function("module", "exports", "require", ...Object.keys(globals), compiled)(mod, mod.exports,
|
||||
key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
test("部门导出提交名称筛选快照,复用人员导出的进度和下载流程", async () => {
|
||||
const requests = []
|
||||
const progress = []
|
||||
const downloads = []
|
||||
const submitted = { taskId: "a".repeat(32), redisKey: "admin:export:dept", status: "PENDING" }
|
||||
const request = options => { requests.push(options); return Promise.resolve({ data: submitted }) }
|
||||
const tasks = load(fs.readFileSync(path.join(root, "api/asyncTask.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request },
|
||||
})
|
||||
const api = load(fs.readFileSync(path.join(root, "api/admin/dept.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request }, "/@/api/asyncTask": tasks,
|
||||
})
|
||||
const hook = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {},
|
||||
"/@/hooks/message": { useMessage: () => ({ error: message => assert.fail(message) }) },
|
||||
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
||||
})
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, "views/admin/system/dept/index.vue"), "utf8")).descriptor
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: "dept.vue", id: "dept" }).errors, [])
|
||||
const page = load(compileScript(descriptor, { id: "dept" }).content, {
|
||||
vue, "/@/api/admin/dept": api,
|
||||
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
|
||||
"vue-i18n": { useI18n: () => ({ t: key => key }) },
|
||||
"/@/stores/userInfo": { useUserInfo: () => ({ userInfos: { roleCodes: [] } }) },
|
||||
}, { ref: vue.ref, reactive: vue.reactive, defineAsyncComponent: vue.defineAsyncComponent })
|
||||
const state = page.default.setup({}, { expose() {} })
|
||||
state.exportProgressRef.value = {
|
||||
startSubmitting: name => progress.push(name), start: value => progress.push(value),
|
||||
show: () => progress.push("show"), submissionFailed: message => assert.fail(message),
|
||||
}
|
||||
state.state.queryForm.deptName = "人事"
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, "/admin/dept/export-async")
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].data, { deptName: "人事" })
|
||||
assert.equal(state.state.queryForm.deptName, "人事")
|
||||
assert.equal(state.exportTaskConfig.progressUrl, "/admin/async-task/progress")
|
||||
assert.equal(state.exportTaskConfig.redisKey, "admin:export:dept")
|
||||
assert.deepEqual(progress, ["部门管理", submitted])
|
||||
state.state.queryForm.deptName = "财务"
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].data.deptName, "人事")
|
||||
assert.equal(progress.at(-1), "show")
|
||||
state.handleExportSuccess({ resultFileUrl: "/admin/sys-file/oss/file?fileName=depts.xlsx", resultFileName: "部门管理.xlsx" })
|
||||
assert.deepEqual(downloads, [["/admin/sys-file/oss/file?fileName=depts.xlsx", "部门管理.xlsx"]])
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
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 corePath = fs.existsSync(path.join(root, 'src/utils/file-access-core.ts'))
|
||||
? path.join(root, 'src/utils/file-access-core.ts') : path.join(root, 'src/sheep/utils/file-access-core.ts');
|
||||
const loaded = new Module(corePath, module);
|
||||
loaded.filename = corePath; loaded.paths = Module._nodeModulePaths(path.dirname(corePath));
|
||||
loaded._compile(ts.transpileModule(fs.readFileSync(corePath, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, corePath);
|
||||
const { fileAccessSignature, fileAccessCanonical, createFileAccess, rewriteFileHtml, rewriteFileNodes } = loaded.exports;
|
||||
const vectors = JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8'));
|
||||
for (const v of vectors) test('cross-language vector: ' + v.fileName, () => {
|
||||
assert.equal(fileAccessCanonical(v.fileName,v.appId,v.kid,v.ts),v.canonical);
|
||||
assert.equal(fileAccessSignature(v.fileName,v.appId,v.kid,v.ts,v.key),v.sign);
|
||||
});
|
||||
let time;
|
||||
function client(baseUrl='https://files.example/api') {
|
||||
time = 1793232000000;
|
||||
return createFileAccess({appId:'mini',kid:'v1',key:vectors[0].key,baseUrl:()=>baseUrl,now:()=>time,allowedOrigins:['https://legacy.example']});
|
||||
}
|
||||
test('prefixes, absolute URLs, and bare names address the same file',()=>{
|
||||
const c=client();
|
||||
for(const value of ['/admin/sys-file/oss/file?fileName=a.jpg','/api/admin/sys-file/oss/file?fileName=a.jpg','https://files.example/api/admin/sys-file/oss/file?fileName=a.jpg','a.jpg']) {
|
||||
const url=c.url(value); assert.match(url,/^https:\/\/files.example\/api\/admin\/sys-file\/oss\/file\?/);
|
||||
assert.equal(new URL(url).searchParams.get('sign'),vectors[0].sign);
|
||||
assert.ok(url.endsWith('fileName=a.jpg'));
|
||||
}
|
||||
});
|
||||
test('relative API base, no duplicate prefixes',()=>{
|
||||
const c=client('/api'); assert.match(c.url('/api/admin/sys-file/oss/file?fileName=a.jpg'),/^\/api\/admin/);
|
||||
});
|
||||
test('foreign URLs and local resources never receive credentials',()=>{
|
||||
const c=client();
|
||||
for(const value of ['https://other.example/admin/sys-file/oss/file?fileName=a.jpg','https://files.example.evil/api/admin/sys-file/oss/file?fileName=a.jpg','data:image/png;base64,AA==','blob:https://files.example/abc','/static/logo.png','https://files.example/qiniu/a.jpg']) assert.equal(c.url(value),value);
|
||||
});
|
||||
test('one decode, unusual names, no duplicated signature and cache renewal',()=>{
|
||||
const c=client(), name='照片 +&%.png', source='/admin/sys-file/oss/file?fileName='+encodeURIComponent(name);
|
||||
const first=c.url(source), params=new URL(first).searchParams;
|
||||
assert.equal(params.get('fileName'),name); assert.equal(params.get('sign'),fileAccessSignature(name,'mini','v1',1793232000,vectors[0].key));
|
||||
assert.equal(c.url(first),first); time+=10*60000; assert.equal(c.url(source),first);
|
||||
time+=16*60000; const renewed=c.url(source); assert.notEqual(renewed,first);
|
||||
assert.equal(c.strip(first),c.strip(renewed)); assert.equal(c.strip(source),source);
|
||||
});
|
||||
test('reject ambiguous file names and never silently use an empty key',()=>{
|
||||
const c=client(); assert.throws(()=>c.url('/admin/sys-file/oss/file?fileName=a&fileName=b'));
|
||||
assert.throws(()=>c.url('/admin/sys-file/oss/file?fileName='));
|
||||
assert.throws(()=>fileAccessSignature('a','mini','v1',1,''));
|
||||
});
|
||||
test('clock correction renews a skewed signature',()=>{
|
||||
const c=client();time+=86400000;c.synchronize(1793232000000,1800,0);
|
||||
assert.equal(new URL(c.url('a.jpg')).searchParams.get('ts'),'1793232000');
|
||||
});
|
||||
test('routine clock synchronization preserves usable signatures and corrects large clock changes', () => {
|
||||
const c = client(), first = c.url('a.jpg');
|
||||
time += 2000; c.synchronize(time + 4000, 1800, 0);
|
||||
assert.equal(c.url('a.jpg'), first);
|
||||
c.synchronize(time + 86400000, 1800, 0);
|
||||
assert.notEqual(c.url('a.jpg'), first);
|
||||
});
|
||||
test('HTML and rich-text nodes sign for presentation without mutating business values',()=>{
|
||||
const c=client(), html='<p>hello</p><img src="/api/admin/sys-file/oss/file?fileName=a.jpg"><a href="https://other.example/a">x</a>';
|
||||
const transform=s=>c.protected(s)?c.url(s):s;
|
||||
const rendered=rewriteFileHtml(html,transform);assert.match(rendered,/&sign=/);assert.match(rendered,/href="https:\/\/other.example\/a"/);
|
||||
const saved=rewriteFileHtml(rendered,s=>c.strip(s));assert.doesNotMatch(saved,/[?&](?:amp;)?(?:sign|ts|kid|appId)=/);assert.match(saved,/fileName=a.jpg/);
|
||||
const nodes=[{name:'img',attrs:{src:'/api/admin/sys-file/oss/file?fileName=a.jpg'}}], before=JSON.stringify(nodes);
|
||||
assert.match(rewriteFileNodes(nodes,transform)[0].attrs.src,/sign=/);assert.equal(JSON.stringify(nodes),before);
|
||||
});
|
||||
|
||||
test('configured deployment prefixes and encoded parameter names remain unambiguous', () => {
|
||||
const c = client('https://files.example/gateway/api');
|
||||
const relative = '/gateway/api/admin/sys-file/oss/file?%66ileName=a.jpg';
|
||||
const url = c.url(relative);
|
||||
assert.equal(new URL(url).pathname, '/gateway/api/admin/sys-file/oss/file');
|
||||
assert.equal(new URL(url).searchParams.get('sign'), vectors[0].sign);
|
||||
assert.equal(c.url(url), url);
|
||||
assert.throws(() => c.url(relative + '&fileName=b.jpg'));
|
||||
const special = c.url('/admin/sys-file/oss/file?fileName=a%252Bb.png');
|
||||
assert.equal(new URL(special).searchParams.get('fileName'), 'a%2Bb.png');
|
||||
});
|
||||
|
||||
test('real runtime wrapper initializes, calibrates without login, and renews reactively', async () => {
|
||||
const { computed } = require('vue');
|
||||
const filename = path.join(path.dirname(corePath), 'file-access.ts');
|
||||
const env = {
|
||||
VITE_FILE_ACCESS_APP_ID: 'mini', 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', VITE_FILE_ACCESS_ALLOWED_ORIGINS: 'https://legacy.example',
|
||||
};
|
||||
const runtime = new Module(filename, module); runtime.filename = filename; runtime.paths = loaded.paths;
|
||||
runtime.require = (name) => name === './file-access-core' ? loaded.exports : name === './app' ? { isDevEnv: () => true } : loaded.require(name);
|
||||
const source = fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')');
|
||||
const originalFetch = global.fetch, originalUni = global.uni, realNow = Date.now;
|
||||
const serverTime = realNow() + 86400000;
|
||||
const info = { data: { serverTime, ttlSeconds: 1800 } };
|
||||
global.fetch = async (url, options) => {
|
||||
assert.equal(url, 'https://files.example/api/admin/sys-file/oss/access-info');
|
||||
assert.equal(options.credentials, 'omit'); return { ok: true, json: async () => info };
|
||||
};
|
||||
global.uni = { request: options => { assert.match(options.url, /oss\/access-info$/); options.success({ statusCode: 200, data: info }); } };
|
||||
let elapsed = 0;
|
||||
Date.now = () => realNow() + elapsed;
|
||||
try {
|
||||
runtime._compile(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, filename);
|
||||
const api = runtime.exports, raw = '/admin/sys-file/oss/file?fileName=a.jpg';
|
||||
const shown = computed(() => api.accessFileUrl(raw));
|
||||
const first = shown.value;
|
||||
assert.match(first, /sign=/);
|
||||
assert.match(api.accessFileUrl('https://legacy.example/api/admin/sys-file/oss/file?fileName=a.jpg'), /sign=/);
|
||||
await api.refreshFileAccess(true);
|
||||
assert.equal(Number(new URL(shown.value).searchParams.get('ts')), Math.floor(serverTime / 1000));
|
||||
const calibrated = shown.value;
|
||||
elapsed = 26 * 60000;
|
||||
api.fileAccessRevision.value++;
|
||||
assert.notEqual(shown.value, calibrated);
|
||||
assert.equal(api.originalFileUrl(shown.value), api.originalFileUrl(first));
|
||||
assert.equal(raw, '/admin/sys-file/oss/file?fileName=a.jpg');
|
||||
assert.equal(api.accessFileUrl('/static/logo.png'), '/static/logo.png');
|
||||
assert.equal(api.accessFileUrl('blob:https://files.example/test'), 'blob:https://files.example/test');
|
||||
} finally { global.fetch = originalFetch; global.uni = originalUni; Date.now = realNow; }
|
||||
});
|
||||
|
||||
test('frontend proxy /api is normalized to the configured file-server prefix', () => {
|
||||
const c = client('https://files.example');
|
||||
assert.equal(new URL(c.url('/api/admin/sys-file/oss/file?fileName=a.jpg')).pathname, '/admin/sys-file/oss/file');
|
||||
assert.equal(c.resolve('/api/admin/sys-file/oss/access-info'), 'https://files.example/admin/sys-file/oss/access-info');
|
||||
assert.equal(new URL(c.url('https://legacy.example/api/admin/sys-file/oss/file?fileName=a.jpg')).pathname, '/api/admin/sys-file/oss/file');
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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 } = require('@vue/compiler-sfc')
|
||||
const { runtime, compile, root } = require('./helpers/file-access-runtime.cjs')
|
||||
|
||||
const raw = file => '/admin/sys-file/oss/file?fileName=' + file
|
||||
const settle = async () => { await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
|
||||
|
||||
function mountImages(api) {
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, 'src/components/FileImage/index.vue'), 'utf8')).descriptor
|
||||
const component = compileScript(descriptor, { id: 'file-image-regression', inlineTemplate: true }).content
|
||||
const loaded = { exports: {} }
|
||||
const loads = [], failures = []
|
||||
// Element Plus restarts loading when its src prop changes. Record that boundary
|
||||
// while executing the real wrapper's setup, template, events and Vue lifecycle.
|
||||
const ElImage = vue.defineComponent({
|
||||
props: ['src', 'previewSrcList'], emits: ['load', 'error', 'show', 'switch', 'close'],
|
||||
setup(props, { emit }) {
|
||||
vue.watch(() => props.src, value => loads.push(value), { immediate: true })
|
||||
return () => vue.h('img', {
|
||||
src: props.src, preview: props.previewSrcList,
|
||||
onLoad: event => emit('load', event), onError: event => emit('error', event),
|
||||
onShow: () => emit('show'), onSwitch: index => emit('switch', index),
|
||||
})
|
||||
},
|
||||
})
|
||||
new Function('module', 'exports', 'require', compile(component))(loaded, loaded.exports, id => {
|
||||
if (id === 'element-plus') return { ElImage }
|
||||
if (id === '/@/utils/file-access') return api
|
||||
return require(id)
|
||||
})
|
||||
const renderer = vue.createRenderer({
|
||||
createElement: type => ({ type, props: {}, children: [] }), createText: text => ({ text }), createComment: text => ({ text }),
|
||||
setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text },
|
||||
parentNode: node => node.parent, nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1],
|
||||
patchProp: (node, key, previous, next) => { node.props[key] = next },
|
||||
insert(node, parent, anchor) {
|
||||
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||
node.parent = parent
|
||||
const index = anchor ? parent.children.indexOf(anchor) : -1
|
||||
parent.children.splice(index < 0 ? parent.children.length : index, 0, node)
|
||||
},
|
||||
remove(node) { if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1) },
|
||||
})
|
||||
const sources = vue.ref([raw('good.jpg'), raw('other.jpg')])
|
||||
const container = { children: [] }
|
||||
const app = renderer.createApp({
|
||||
setup() {
|
||||
return () => vue.h('section', sources.value.map((source, index) => vue.h(loaded.exports.default, {
|
||||
key: index, src: api.accessFileUrl(source), previewSrcList: api.fileUrls([source]),
|
||||
onError: () => failures.push(index),
|
||||
})))
|
||||
},
|
||||
})
|
||||
app.mount(container)
|
||||
return { loads, failures, sources, images: () => container.children[0].children, close: () => app.unmount() }
|
||||
}
|
||||
|
||||
test('one failed image renews only itself, coalesces concurrent retries, and remains bounded', async () => {
|
||||
const env = runtime(), { api, clock } = env
|
||||
try {
|
||||
const good = api.fileUrl(raw('good.jpg')), bad = api.fileUrl(raw('missing.jpg'))
|
||||
clock.now += 1000
|
||||
assert.deepEqual(await Promise.all([api.retryFileAccess(raw('missing.jpg')), api.retryFileAccess(raw('missing.jpg'))]), [true, true])
|
||||
assert.equal(clock.requests, 1)
|
||||
assert.equal(api.fileUrl(raw('good.jpg')), good)
|
||||
assert.notEqual(api.fileUrl(raw('missing.jpg')), bad)
|
||||
assert.equal(await api.retryFileAccess(raw('missing.jpg')), false)
|
||||
assert.equal(clock.requests, 1)
|
||||
const offline = api.fileUrl(raw('offline.jpg'))
|
||||
clock.offline = true; clock.now += 1000
|
||||
assert.equal(await api.retryFileAccess(raw('offline.jpg')), true)
|
||||
assert.notEqual(api.fileUrl(raw('offline.jpg')), offline)
|
||||
assert.equal(api.fileUrl(raw('good.jpg')), good)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('owned anchors fetch through the app downloader, while external and already handled links keep their behaviour', () => {
|
||||
const env = runtime(), calls = []
|
||||
try {
|
||||
const event = (href, prevented = false) => ({
|
||||
defaultPrevented: prevented,
|
||||
target: { closest: () => ({ getAttribute: name => name === 'href' ? href : '' }) },
|
||||
preventDefault() { this.defaultPrevented = true }, stopImmediatePropagation() { this.stopped = true },
|
||||
})
|
||||
const source = raw(encodeURIComponent('附件 +&%.pdf')), signed = env.api.fileUrl(source)
|
||||
const click = event(signed)
|
||||
assert.equal(env.api.handleFileLinkClick(click, (...args) => calls.push(args)), true)
|
||||
assert.equal(click.defaultPrevented, true); assert.equal(click.stopped, true)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0][2], '附件 +&%.pdf')
|
||||
assert.doesNotMatch(calls[0][0], /[?&](?:sign|ts|kid|appId)=/)
|
||||
for (const href of ['https://other.example/file.pdf', 'blob:local-document', '/home']) {
|
||||
const outside = event(href)
|
||||
assert.equal(env.api.handleFileLinkClick(outside, () => assert.fail('unrelated link intercepted')), false)
|
||||
assert.equal(outside.defaultPrevented, false)
|
||||
}
|
||||
assert.equal(env.api.handleFileLinkClick(event(signed, true), () => assert.fail('already handled')), false)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('loaded thumbnails never reload on minute checks, expiry, or foreground clock sync; previews receive fresh signatures', async () => {
|
||||
const env = runtime(), page = mountImages(env.api)
|
||||
try {
|
||||
page.images().forEach(image => image.props.onLoad(new Event('load')))
|
||||
const before = page.images().map(image => image.props.src)
|
||||
for (let minute = 0; minute < 31; minute++) {
|
||||
env.clock.now += 60000
|
||||
env.api.fileAccessRevision.value++
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(page.images().map(image => image.props.src), before)
|
||||
}
|
||||
await env.api.refreshFileAccess(true); await settle()
|
||||
assert.equal(page.loads.length, 2)
|
||||
assert.deepEqual(page.images().map(image => image.props.src), before)
|
||||
page.images()[0].props.onShow(); await settle()
|
||||
const preview = page.images()[0].props.preview[0]
|
||||
assert.notEqual(preview, before[0])
|
||||
assert.equal(preview, env.api.fileUrl(raw('good.jpg')))
|
||||
assert.ok(Number(new URL(preview).searchParams.get('ts')) + 1800 > env.clock.now / 1000)
|
||||
assert.equal(page.loads.length, 2)
|
||||
assert.equal(page.sources.value[0], raw('good.jpg'))
|
||||
env.clock.now += 31 * 60000; env.api.fileAccessRevision.value++; await settle()
|
||||
assert.equal(page.images()[0].props.preview[0], preview)
|
||||
page.images()[0].props.onSwitch(0); await settle()
|
||||
assert.notEqual(page.images()[0].props.preview[0], preview)
|
||||
} finally { page.close(); env.close() }
|
||||
})
|
||||
|
||||
test('failed thumbnails retry once without resetting neighbours; a changed business image still loads', async () => {
|
||||
const env = runtime(), page = mountImages(env.api)
|
||||
try {
|
||||
page.images()[1].props.onLoad(new Event('load'))
|
||||
const neighbour = page.images()[1].props.src
|
||||
env.clock.now += 1000
|
||||
page.images()[0].props.onError(new Event('error')); await settle()
|
||||
assert.equal(page.loads.length, 3)
|
||||
assert.equal(page.images()[1].props.src, neighbour)
|
||||
page.images()[0].props.onError(new Event('error')); await settle()
|
||||
assert.equal(page.loads.length, 3)
|
||||
assert.deepEqual(page.failures, [0])
|
||||
env.api.fileAccessRevision.value++; await settle()
|
||||
assert.equal(page.loads.length, 3)
|
||||
page.sources.value[0] = raw('replacement.jpg'); await settle()
|
||||
assert.equal(new URL(page.images()[0].props.src).searchParams.get('fileName'), 'replacement.jpg')
|
||||
assert.equal(page.images()[1].props.src, neighbour)
|
||||
} finally { page.close(); env.close() }
|
||||
})
|
||||
|
||||
test('offscreen images keep a valid URL for their first lazy request without reloading an already visible image', async () => {
|
||||
const env = runtime(), page = mountImages(env.api)
|
||||
try {
|
||||
page.images()[1].props.onLoad(new Event('load'))
|
||||
const before = page.images().map(image => image.props.src)
|
||||
env.clock.now += 31 * 60000; env.api.fileAccessRevision.value++; await settle()
|
||||
assert.notEqual(page.images()[0].props.src, before[0])
|
||||
assert.equal(new URL(page.images()[0].props.src).searchParams.get('ts'), String(env.clock.now / 1000))
|
||||
assert.equal(page.images()[1].props.src, before[1])
|
||||
assert.equal(env.clock.requests, 0)
|
||||
} finally { page.close(); env.close() }
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
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 compile = source => ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText
|
||||
|
||||
function runtime() {
|
||||
const clock = { now: 1793232000000, requests: 0, offline: false }
|
||||
const realNow = Date.now, realFetch = global.fetch
|
||||
Date.now = () => clock.now
|
||||
global.fetch = async () => {
|
||||
clock.requests++
|
||||
if (clock.offline) throw new Error('Offline')
|
||||
return { ok: true, json: async () => ({ data: { serverTime: clock.now, ttlSeconds: 1800 } }) }
|
||||
}
|
||||
const filename = path.join(root, 'src/utils/file-access.ts')
|
||||
const core = new Module(path.join(root, 'src/utils/file-access-core.ts'), module)
|
||||
core.filename = core.id; core.paths = Module._nodeModulePaths(path.dirname(core.filename))
|
||||
core._compile(compile(fs.readFileSync(core.filename, 'utf8')), core.filename)
|
||||
const loaded = new Module(filename, module)
|
||||
loaded.filename = filename; loaded.paths = core.paths
|
||||
loaded.require = name => name === './file-access-core' ? core.exports : core.require(name)
|
||||
const env = {
|
||||
VITE_FILE_ACCESS_APP_ID: 'admin', VITE_FILE_ACCESS_KEY_ID: 'v1',
|
||||
VITE_FILE_ACCESS_KEY: JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8'))[0].key,
|
||||
VITE_IMAGE_BASE_PATH: 'https://files.example/api', VITE_FILE_ACCESS_ALLOWED_ORIGINS: '',
|
||||
}
|
||||
loaded._compile(compile(fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')')), filename)
|
||||
return { api: loaded.exports, clock, close() { Date.now = realNow; global.fetch = realFetch } }
|
||||
}
|
||||
|
||||
module.exports = { runtime, compile, root }
|
||||
@@ -0,0 +1,93 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
const vue = require("vue")
|
||||
const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||
const root = path.join(__dirname, "../src")
|
||||
|
||||
function load(source, mocks, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 },
|
||||
}).outputText
|
||||
const mod = { exports: {} }
|
||||
new Function("module", "exports", "require", ...Object.keys(globals), compiled)(mod, mod.exports,
|
||||
key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const requests = [], progress = [], downloads = [], errors = []
|
||||
const submitted = { taskId: "a".repeat(32), redisKey: "admin:export:log", status: "PENDING" }
|
||||
const request = options => { requests.push(options); return Promise.resolve({ data: submitted }) }
|
||||
const tasks = load(fs.readFileSync(path.join(root, "api/asyncTask.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request },
|
||||
})
|
||||
const api = load(fs.readFileSync(path.join(root, "api/admin/log.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request }, "/@/api/asyncTask": tasks,
|
||||
})
|
||||
const messages = { useMessage: () => ({ error: message => errors.push(message) }) }
|
||||
const hook = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/message": messages,
|
||||
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
||||
})
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, "views/admin/log/index.vue"), "utf8")).descriptor
|
||||
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,
|
||||
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
|
||||
"vue-i18n": { useI18n: () => ({ t: key => key }) },
|
||||
"/@/hooks/table": { useTable: () => ({ getDataList() {}, tableStyle: {} }) },
|
||||
"/@/hooks/message": messages, "/@/hooks/dict": { useDict: () => ({ log_type: [] }) },
|
||||
}, {
|
||||
ref: vue.ref, reactive: vue.reactive, defineAsyncComponent: vue.defineAsyncComponent,
|
||||
onMounted() {}, useRoute: () => ({ query: {} }),
|
||||
})
|
||||
const state = page.default.setup({}, { expose() {} })
|
||||
state.exportProgressRef.value = {
|
||||
startSubmitting: name => progress.push(name), start: value => progress.push(value),
|
||||
show: () => progress.push("show"), submissionFailed: message => assert.fail(message),
|
||||
}
|
||||
return { state, requests, progress, downloads, errors, submitted }
|
||||
}
|
||||
|
||||
test("日志异步导出固定类型、时间和应用筛选,重复点击继续查看进度,成功后下载", async () => {
|
||||
const { state, requests, progress, downloads, submitted } = setup()
|
||||
Object.assign(state.state.queryForm, {
|
||||
logType: "9", serviceId: "sms", current: 3, size: 10,
|
||||
createTime: ["2026-10-29 00:00:00", "2026-10-29 23:59:59"],
|
||||
})
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, "/admin/log/export-async")
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].data, {
|
||||
logType: "9", serviceId: "sms", createTime: ["2026-10-29 00:00:00", "2026-10-29 23:59:59"],
|
||||
})
|
||||
assert.equal(state.state.queryForm.current, 3)
|
||||
assert.equal(state.state.queryForm.size, 10)
|
||||
assert.equal(state.exportTaskConfig.progressUrl, "/admin/async-task/progress")
|
||||
assert.equal(state.exportTaskConfig.redisKey, "admin:export:log")
|
||||
assert.deepEqual(progress, ["操作日志", submitted])
|
||||
state.state.queryForm.createTime[0] = "2026-10-28 00:00:00"
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].data.createTime[0], "2026-10-29 00:00:00")
|
||||
assert.equal(progress.at(-1), "show")
|
||||
state.handleExportSuccess({ resultFileUrl: "/admin/sys-file/oss/file?fileName=logs.xlsx", resultFileName: "操作日志.xlsx" })
|
||||
assert.deepEqual(downloads, [["/admin/sys-file/oss/file?fileName=logs.xlsx", "操作日志.xlsx"]])
|
||||
})
|
||||
|
||||
test("未选择或清空日志时间时提交空数组,任务失败后可以重新导出", async () => {
|
||||
const { state, requests, errors } = setup()
|
||||
await state.exportExcel()
|
||||
assert.deepEqual(requests[0].data, { logType: "", createTime: [], serviceId: "" })
|
||||
assert.equal(state.state.queryForm.createTime, "")
|
||||
state.handleExportFailure({ message: "当前筛选条件没有可导出的日志" })
|
||||
assert.deepEqual(errors, ["当前筛选条件没有可导出的日志"])
|
||||
state.state.queryForm.createTime = null
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.deepEqual(requests[1].data.createTime, [])
|
||||
})
|
||||
Reference in new issue
Block a user