Files
pj_h5_cloud/tests/file-access.test.cjs
dzc 0fafe8762f fix:图片访问
fix:权限修改
2026-10-29 14:25:30 +08:00

138 lines
9.5 KiB
JavaScript

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('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,/&amp;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');
});
test('rich text preview calls the shared signing path from the JavaScript script', () => {
const filename = path.join(root, 'src/uni_modules/tm-ui/components/tm-html/mp-node.vue');
const content = fs.readFileSync(filename, 'utf8');
const script = content.match(/<script>\s*([\s\S]*?)<\/script>/)[1];
const component = new Module(filename, module); component.filename = filename; component.paths = loaded.paths;
let preview;
component.require = name => name === './mp-node.vue' ? {} : name === '@/sheep/utils/file-access' ? { previewFiles: options => { preview=options; } } : loaded.require(name);
component._compile(ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, filename);
component.exports.default.methods.imgTap.call({$emit(){},opts:[0,0,0,true,0,true]},'/admin/sys-file/oss/file?fileName=a.jpg');
assert.deepEqual(preview.urls,['/admin/sys-file/oss/file?fileName=a.jpg']);
assert.doesNotMatch(content.match(/<script module="handler" lang="wxs">([\s\S]*?)<\/script>/)[1], /file-access/);
});