Files

188 lines
13 KiB
JavaScript
Raw Permalink Normal View History

// Run after HBuilderX MP-WEIXIN compilation; execute the actual generated service with isolated API doubles.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const repo = path.resolve(__dirname, '..');
const sourcePath = path.join(repo, 'packages/finance/services/report-export.uts');
const builtPath = path.resolve(process.argv[2] || path.join(repo, 'unpackage/dist/dev/mp-weixin/packages/finance/services/report-export.js'));
assert.ok(fs.statSync(builtPath).mtimeMs >= fs.statSync(sourcePath).mtimeMs, 'Compile MP-WEIXIN before running this test');
const source = fs.readFileSync(builtPath, 'utf8');
const maxSize = 20 * 1024 * 1024;
const pdf = Buffer.from('%PDF-1.7\n' + 'x'.repeat(2000) + '\n%%EOF\n');
let checks = 0;
function environment() {
const state = { token: 'synthetic-local-only', requests: [], opened: [], deleted: [], reads: [], files: new Map(), appShow: [], cleared: 0, aborted: 0, unlinkFails: false };
class JsonObject {
constructor(value) { Object.assign(this, value); }
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
}
class UtsType { static initProps(options) { return options; } }
const api = {
getSession: () => state.token ? new JsonObject({ token: state.token }) : null,
clearSession: () => { state.cleared++; state.token = ''; }
};
const config = { API_BASE_URL: 'https://isolated.invalid/api/v1' };
const manager = {
unlinkSync(file) {
if (state.unlinkFails) throw Error('simulated unlink failure');
assert.ok(state.files.has(file), 'Delete only a known temporary download');
state.files.delete(file); state.deleted.push(file);
},
statSync: file => {
if (state.statFails) throw Error('simulated stat failure');
return { size: state.size ?? state.files.get(file).length };
},
readFileSync(file, encoding, start = 0, length) {
if (state.readFails) throw Error('simulated read failure');
state.reads.push({ encoding, start, length });
return state.files.get(file).subarray(start, length == null ? undefined : start + length).toString(encoding === 'utf8' ? 'utf8' : 'ascii');
}
};
const uni = {
getFileSystemManager: () => manager,
onAppShow: callback => state.appShow.push(callback),
downloadFile(options) {
state.requests.push(options); state.request = options;
return {
abort() { state.aborted++; options.fail({ errMsg: 'downloadFile:fail abort' }); },
onHeadersReceived(callback) { state.headers = callback; },
onProgressUpdate(callback) { state.progress = callback; }
};
},
openDocument(options) { state.opened.push(options); }
};
const exports = {};
vm.runInNewContext(source, {
exports,
require(module) {
if (module.endsWith('/common/vendor.js')) return { index: uni, UTSJSONObject: JsonObject, UTS: { UTSType: UtsType, JSON: { parse: text => new JsonObject(JSON.parse(text)) } } };
if (module.endsWith('/services/api.js')) return api;
if (module.endsWith('/services/config.js')) return config;
throw Error('Unexpected generated dependency: ' + module);
}
}, { filename: builtPath });
state.begin = (id = '45', revision = '72') => {
const operation = exports.downloadReportPdf(id, revision);
operation.result = operation.done.then(message => ({ ok: true, message }), error => ({ ok: false, message: error.message }));
return operation;
};
state.finish = (bytes = pdf, status = 200, mime = 'application/pdf', header = {}) => {
state.headers({ header: { 'Content-Type': mime, 'Content-Length': String(bytes.length), ...header } });
const file = 'wxfile://tmp/isolated-' + (state.requests.length) + '.pdf';
state.files.set(file, bytes);
state.request.success({ tempFilePath: file, statusCode: status });
return file;
};
state.config = config;
return state;
}
async function rejected(state, operation, message) {
const result = await operation.result;
assert.equal(result.ok, false); assert.match(result.message, message);
assert.equal(state.opened.length, 0, 'Invalid or stale file must never reach the viewer');
}
async function test(label, callback) {
await callback(); checks++; console.log('PASS: ' + label);
}
async function main() {
await test('authenticated historical download, bounded validation and preview menu', async () => {
const s = environment(), op = s.begin();
assert.equal(s.request.url, 'https://isolated.invalid/api/v1/assessments/45/report.pdf?revisionId=72');
assert.equal(s.request.header.Authorization, 'Bearer synthetic-local-only');
assert.ok(!s.request.url.includes(s.token)); assert.equal(s.request.timeout, 45000);
const file = s.finish();
assert.equal(s.opened[0].filePath, file); assert.equal(s.opened[0].fileType, 'pdf'); assert.equal(s.opened[0].showMenu, true);
assert.ok(s.reads.every(read => read.length <= 1024));
op.cancel(); assert.equal(s.aborted, 0, 'Native preview must survive parent onHide');
s.opened[0].success(); assert.equal((await op.result).ok, true);
assert.ok(s.files.has(file), 'Keep temporary file while previewing');
s.appShow.forEach(callback => callback()); assert.equal(s.files.size, 0, 'Remove file on return to app');
});
await test('default revision and next export cleanup', async () => {
const s = environment(), first = s.begin('45', ''); assert.ok(!s.request.url.includes('?'));
const file = s.finish(); s.opened[0].success(); await first.result;
const second = s.begin(); assert.ok(s.deleted.includes(file)); second.cancel(); await second.result;
});
for (const [id, revision] of [['0', '72'], ['45', '0'], ['45', '72&token=bad'], ['bad', '']]) {
await test('invalid assessment/revision ' + id + '/' + revision, async () => {
const s = environment(), op = s.begin(id, revision); await rejected(s, op, /编号无效/); assert.equal(s.requests.length, 0);
});
}
await test('no session does not download', async () => { const s = environment(); s.token = ''; const op = s.begin(); await rejected(s, op, /登录已过期/); assert.equal(s.requests.length, 0); });
await test('HTTPS required for mini program', async () => { const s = environment(); s.config.API_BASE_URL = 'http://isolated.invalid'; const op = s.begin(); await rejected(s, op, /HTTPS/); assert.equal(s.requests.length, 0); });
await test('401 clears only the current session and deletes error file', async () => {
const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"expired"}'), 401, 'application/json');
await rejected(s, op, /登录已过期/); assert.equal(s.cleared, 1); assert.equal(s.files.size, 0);
});
await test('late 401 cannot clear another account', async () => {
const s = environment(), op = s.begin(); s.token = 'another-synthetic-token';
s.finish(Buffer.from('{"msg":"expired"}'), 401, 'application/json'); await rejected(s, op, /状态已变化/);
assert.equal(s.cleared, 0); assert.equal(s.token, 'another-synthetic-token'); assert.equal(s.files.size, 0);
});
for (const status of [404, 409, 503]) {
await test('JSON ' + status + ' is shown as error, never opened as PDF', async () => {
const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"报告暂不可用"}'), status, 'application/json; charset=utf-8');
await rejected(s, op, /报告暂不可用/); assert.equal(s.files.size, 0);
});
}
await test('JSON with HTTP200 is not a PDF', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"错误回包"}'), 200, 'application/json'); await rejected(s, op, /错误回包/); });
await test('HTML or incorrect MIME cannot reach viewer', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'text/html'); await rejected(s, op, /暂不可下载/); });
await test('malformed JSON safely fails and cleans file', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{broken'), 503, 'application/json'); await rejected(s, op, /内容异常/); assert.equal(s.files.size, 0); });
for (const [label, bytes] of [['wrong PDF signature', Buffer.from('HTML!' + 'x'.repeat(100) + '%%EOF')], ['missing EOF', Buffer.from('%PDF-' + 'x'.repeat(100))], ['empty body', Buffer.alloc(0)], ['tiny body', Buffer.from('%PDF-%%EOF')]]) {
await test(label, async () => { const s = environment(), op = s.begin(); s.finish(bytes); await rejected(s, op, /不是完整PDF/); assert.equal(s.files.size, 0); });
}
await test('declared length mismatch rejects truncated binary', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'application/pdf', { 'Content-Length': pdf.length + 1 }); await rejected(s, op, /不是完整PDF/); });
await test('compressed content length need not equal decoded file', async () => {
const s = environment(), op = s.begin(); s.finish(pdf, 200, 'application/pdf', { 'Content-Encoding': 'gzip', 'Content-Length': '500' }); s.opened[0].success(); assert.equal((await op.result).ok, true);
});
await test('oversize header aborts before file read', async () => {
const s = environment(), op = s.begin(); s.headers({ header: { 'Content-Length': maxSize + 1 } }); await rejected(s, op, /文件过大/); assert.equal(s.aborted, 1); assert.equal(s.reads.length, 0);
});
for (const progress of [{ totalBytesWritten: maxSize + 1, totalBytesExpectedToWrite: 0 }, { totalBytesWritten: 0, totalBytesExpectedToWrite: maxSize + 1 }]) {
await test('oversize progress aborts download', async () => { const s = environment(), op = s.begin(); s.progress(progress); await rejected(s, op, /文件过大/); assert.equal(s.aborted, 1); });
}
await test('oversize actual file rejected before reading', async () => { const s = environment(), op = s.begin(); s.size = maxSize + 1; s.finish(); await rejected(s, op, /文件过大/); assert.equal(s.reads.length, 0); });
await test('exactly 20MiB accepted with bounded reads', async () => {
const s = environment(), op = s.begin(), bytes = Buffer.alloc(maxSize, 'x');
bytes.write('%PDF-1.7\n'); bytes.write('%%EOF\n', maxSize - 6);
s.progress({ totalBytesWritten: maxSize, totalBytesExpectedToWrite: maxSize }); s.finish(bytes);
s.opened[0].success(); assert.equal((await op.result).ok, true); assert.equal(s.aborted, 0);
assert.equal(s.reads.reduce((sum, read) => sum + read.length, 0), 1029);
});
await test('case insensitive MIME and missing content length accepted', async () => {
const s = environment(), op = s.begin(); s.finish(pdf, 200, 'APPLICATION/PDF; charset=binary', { 'Content-Length': '' });
s.opened[0].success(); assert.equal((await op.result).ok, true);
});
await test('cancellation rejects immediately and cleans late successful response', async () => {
const s = environment(), op = s.begin(); op.cancel(); await rejected(s, op, /下载已取消/);
s.finish(); assert.equal(s.opened.length, 0); assert.equal(s.files.size, 0);
});
await test('account switch during progress aborts', async () => { const s = environment(), op = s.begin(); s.token = 'other'; s.progress({ totalBytesWritten: 1, totalBytesExpectedToWrite: 10 }); await rejected(s, op, /状态已变化/); assert.equal(s.aborted, 1); });
await test('account switch before preview success rejects and cleans', async () => {
const s = environment(), op = s.begin(); s.finish(); s.token = 'other'; s.opened[0].success();
const result = await op.result; assert.equal(result.ok, false); assert.match(result.message, /状态已变化/); assert.equal(s.files.size, 0);
});
await test('preview failure cleans file and does not report success', async () => {
const s = environment(), op = s.begin(); s.finish(); s.opened[0].fail(); const result = await op.result; assert.equal(result.ok, false); assert.match(result.message, /无法打开/); assert.equal(s.files.size, 0);
});
for (const flag of ['statFails', 'readFails']) {
await test('filesystem ' + flag + ' fails safely', async () => { const s = environment(), op = s.begin(); s[flag] = true; s.finish(); await rejected(s, op, /文件读取失败/); assert.equal(s.files.size, 0); });
}
for (const [reason, expected] of [['downloadFile:fail url not in domain list', /域名未配置/], ['downloadFile:fail timeout', /生成超时/], ['downloadFile:fail connection reset', /检查网络/]]) {
await test(reason, async () => { const s = environment(), op = s.begin(); s.request.fail({ errMsg: reason }); await rejected(s, op, expected); });
}
await test('temporary unlink failure is retried on foreground return', async () => {
const s = environment(), op = s.begin(); s.unlinkFails = true; s.finish(Buffer.from('bad')); await rejected(s, op, /不是完整PDF/);
assert.equal(s.files.size, 1); s.unlinkFails = false; s.appShow.forEach(callback => callback()); assert.equal(s.files.size, 0);
});
console.log(`PASS: ${checks} generated MP-WEIXIN PDF export checks. No network or database access.`);
}
main().catch(error => { console.error(error); process.exitCode = 1; });