import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { attachPortalStaticDeliveryRoutes, } from './portal-static-delivery-routes.mjs'; function createResponse() { return { statusCode: 200, body: undefined, contentType: null, headers: {}, redirectValue: null, sentFile: null, ended: false, status(code) { this.statusCode = code; return this; }, type(value) { this.contentType = value; return this; }, set(name, value) { this.headers[name] = value; return this; }, json(body) { this.body = body; return this; }, send(body) { this.body = body; return this; }, sendFile(filePath, callback) { this.sentFile = filePath; callback?.(null); return this; }, redirect(status, location) { this.statusCode = status; this.redirectValue = location; return this; }, end() { this.ended = true; return this; }, }; } function createSetup(overrides = {}) { const routes = []; const staticCalls = []; const calls = []; const app = { get(routePath, ...handlers) { routes.push({ method: 'get', path: routePath, handlers, }); }, use(routePath, ...handlers) { if (typeof routePath === 'function') { routes.push({ method: 'use', path: null, handlers: [routePath, ...handlers], }); return; } routes.push({ method: 'use', path: routePath, handlers, }); }, }; attachPortalStaticDeliveryRoutes({ app, h5Root: '/project', host: '127.0.0.1', port: 8081, publishRootDir: 'MindSpace', usersRoot: '/users', resolveRequestOrigin: () => 'https://portal.example', staticMiddleware(directory, options) { staticCalls.push([directory, options]); return function staticHandler() {}; }, fetchFn: async (url, options) => { calls.push(['fetch', url, options]); return { ok: true, status: 200, async text() { return 'upstream'; }, }; }, injectOgTagsFn(html, context) { calls.push(['inject', html, context]); return 'injected'; }, extractSharePreviewMetaFn(html, context) { calls.push(['extract', html, context]); return { title: 'Preview' }; }, renderWechatSharePreviewHtmlFn( preview, options, ) { calls.push(['render', preview, options]); return 'preview'; }, ...overrides, }); return { routes, staticCalls, calls, route(method, routePath) { return routes.find( (route) => route.method === method && route.path === routePath, ); }, }; } async function invoke( setup, method, routePath, request = {}, ) { const route = setup.route(method, routePath); assert.ok( route, `${method.toUpperCase()} ${routePath}`, ); const response = createResponse(); let nextCalled = false; await route.handlers.at(-1)( { path: '/', url: '/', query: {}, get: () => '', ...request, }, response, () => { nextCalled = true; }, ); return { response, nextCalled }; } test('registers static delivery routes and middleware in legacy order', () => { const setup = createSetup(); assert.deepEqual( setup.routes.map(({ method, path: routePath }) => [ method, routePath instanceof RegExp ? routePath.toString() : routePath, ]), [ ['use', '/temp'], ['use', '/user'], ['get', '/MindSpace/wiki/*'], ['get', '/temp/wiki/*'], ['use', '/plaza-covers'], ['use', '/brand'], ['use', '/assets'], ['get', '/dev/wechat-share-preview'], ['use', '/dev'], [ 'get', '/^\\/MP_verify_[A-Za-z0-9]+\\.txt$/', ], ['use', '/auth'], ['use', '/admin-api'], ['use', null], ['get', '*'], ], ); assert.deepEqual(setup.staticCalls, [ [ '/project/public/plaza-covers', { maxAge: '7d' }, ], [ '/project/public/brand', { maxAge: '7d' }, ], [ '/project/public/assets', { maxAge: '1d' }, ], ['/project/public/dev', { maxAge: 0 }], [ '/project/dist', { index: 'index.html' }, ], ]); }); test('preserves temp and wiki redirects plus Wiki SPA delivery', async () => { const setup = createSetup(); const temp = await invoke( setup, 'use', '/temp', { url: '/wiki/page' }, ); assert.equal(temp.response.statusCode, 301); assert.equal( temp.response.redirectValue, '/MindSpace/wiki/page', ); const wiki = await invoke( setup, 'get', '/MindSpace/wiki/*', ); assert.equal( wiki.response.sentFile, '/project/MindSpace/wiki/index.html', ); const legacyWiki = await invoke( setup, 'get', '/temp/wiki/*', { url: '/temp/wiki/topic' }, ); assert.equal( legacyWiki.response.redirectValue, '/MindSpace/wiki/topic', ); }); test('preserves public user file traversal and missing-user responses', async () => { const setup = createSetup({ fsApi: { existsSync() { return false; }, }, }); const empty = await invoke( setup, 'use', '/user', { path: '/' }, ); assert.equal(empty.nextCalled, true); const missing = await invoke( setup, 'use', '/user', { path: '/john/page.html' }, ); assert.equal(missing.response.statusCode, 404); assert.deepEqual(missing.response.body, { message: '用户不存在', }); const traversal = await invoke( setup, 'use', '/user', { path: '/../outside.txt' }, ); assert.equal(traversal.response.statusCode, 403); assert.deepEqual(traversal.response.body, { message: '禁止访问', }); }); test('serves public user files and directory indexes', async (t) => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'portal-static-'), ); t.after(() => fs.rmSync(root, { recursive: true, force: true, })); fs.mkdirSync( path.join(root, 'john', 'guide'), { recursive: true }, ); fs.writeFileSync( path.join(root, 'john', 'guide', 'index.html'), 'guide', ); fs.writeFileSync( path.join(root, 'john', 'note.txt'), 'note', ); const setup = createSetup({ usersRoot: root, }); const directory = await invoke( setup, 'use', '/user', { path: '/john/guide' }, ); assert.equal( directory.response.sentFile, path.join( root, 'john', 'guide', 'index.html', ), ); const file = await invoke( setup, 'use', '/user', { path: '/john/note.txt' }, ); assert.equal( file.response.sentFile, path.join(root, 'john', 'note.txt'), ); }); test('preserves WeChat share preview validation and rendering', async () => { const setup = createSetup(); const missing = await invoke( setup, 'get', '/dev/wechat-share-preview', ); assert.equal(missing.response.statusCode, 400); assert.match(missing.response.body, /缺少 url 参数/); const rendered = await invoke( setup, 'get', '/dev/wechat-share-preview', { query: { url: '/MindSpace/john/page.html', }, get: () => 'test-agent', }, ); assert.equal( rendered.response.body, 'preview', ); assert.equal( rendered.response.headers['Cache-Control'], 'no-store', ); assert.deepEqual(setup.calls[0], [ 'fetch', 'https://portal.example/MindSpace/john/page.html', { headers: { 'user-agent': 'test-agent', accept: 'text/html,*/*', }, redirect: 'follow', }, ]); }); test('preserves verification, API fallbacks, and SPA fallback', async () => { const missingSetup = createSetup({ fsApi: { existsSync() { return false; }, }, }); const verification = missingSetup.routes.find( (route) => route.path instanceof RegExp, ); const missingResponse = createResponse(); verification.handlers[0]( { path: '/MP_verify_abc123.txt' }, missingResponse, ); assert.equal(missingResponse.statusCode, 404); assert.equal(missingResponse.ended, true); for (const routePath of [ '/auth', '/admin-api', ]) { const fallback = await invoke( missingSetup, 'use', routePath, ); assert.equal( fallback.response.statusCode, 404, ); assert.match( fallback.response.body.message, /接口不存在/, ); } const spa = await invoke( missingSetup, 'get', '*', ); assert.equal( spa.response.sentFile, '/project/dist/index.html', ); });