435 lines
8.8 KiB
JavaScript
435 lines
8.8 KiB
JavaScript
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 '<html>upstream</html>';
|
|
},
|
|
};
|
|
},
|
|
injectOgTagsFn(html, context) {
|
|
calls.push(['inject', html, context]);
|
|
return '<html>injected</html>';
|
|
},
|
|
extractSharePreviewMetaFn(html, context) {
|
|
calls.push(['extract', html, context]);
|
|
return { title: 'Preview' };
|
|
},
|
|
renderWechatSharePreviewHtmlFn(
|
|
preview,
|
|
options,
|
|
) {
|
|
calls.push(['render', preview, options]);
|
|
return '<html>preview</html>';
|
|
},
|
|
...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,
|
|
'<html>preview</html>',
|
|
);
|
|
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',
|
|
);
|
|
});
|