Files
memind/server/portal-publication-routes.test.mjs
T

667 lines
14 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
attachPortalPublicationRoutes,
renderPublicationPasswordGate,
renderPublicHomepage,
} from './portal-publication-routes.mjs';
function createResponse() {
return {
statusCode: 200,
body: undefined,
headers: {},
headersSent: false,
redirectValue: null,
sentFile: null,
status(code) {
this.statusCode = code;
return this;
},
set(name, value) {
this.headers[name] = value;
return this;
},
send(body) {
this.body = body;
return this;
},
json(body) {
this.body = body;
return this;
},
redirect(status, location) {
this.statusCode = status;
this.redirectValue = location;
return this;
},
sendFile(filePath, callback) {
this.sentFile = filePath;
callback?.(null);
return this;
},
};
}
function createRequest(overrides = {}) {
return {
path: '/',
originalUrl: '/',
params: {},
query: {},
body: {},
userSession: null,
userToken: null,
get(name) {
return {
'user-agent': 'test-agent',
referer: 'https://referrer.example',
}[name];
},
...overrides,
};
}
function createSetup(overrides = {}) {
const routes = [];
const calls = [];
const urlencodedBody = () => {};
let authPool = {
async query(sql, params) {
calls.push(['query', sql, params]);
if (sql.includes('pr.user_id')) {
return [[{
user_id: 'user-1',
page_id: 'page-1',
}]];
}
return [[{ id: 'user-1' }]];
},
};
let mindSpacePages = {
async renderThumbnail(userId, pageId) {
calls.push([
'thumbnail',
userId,
pageId,
]);
return '<svg>thumbnail</svg>';
},
};
let publicResult = {
ownerId: 'user-1',
workspaceRelativePath:
'public/page.html',
publication: {
accessMode: 'public',
},
};
let mindSpacePublications = {
async resolvePublic(...args) {
calls.push(['resolve-public', ...args]);
return publicResult;
},
async getPublicHomepage(ownerSlug) {
calls.push(['homepage', ownerSlug]);
return {
owner: {
displayName: 'Alice',
slug: 'alice',
},
pages: [],
pageCount: 0,
totalViews: 0,
};
},
async resolvePrivateLink(...args) {
calls.push(['resolve-private', ...args]);
return {
publication: {
accessMode: 'private_link',
},
};
},
};
let userAuth = {
async getMe(token) {
calls.push(['get-me', token]);
return { id: 'viewer-1' };
},
};
const app = {
use(routePath, ...handlers) {
if (typeof routePath === 'function') {
routes.push({
method: 'use',
path: null,
handlers: [
routePath,
...handlers,
],
});
return;
}
routes.push({
method: 'use',
path: routePath,
handlers,
});
},
get(routePath, ...handlers) {
routes.push({
method: 'get',
path: routePath,
handlers,
});
},
post(routePath, ...handlers) {
routes.push({
method: 'post',
path: routePath,
handlers,
});
},
};
const defaults = {
app,
urlencodedBody,
userAuthReady: Promise.resolve(),
h5Root: '/project',
getAuthPool: () => authPool,
getMindSpacePages: () => mindSpacePages,
getMindSpacePublications: () =>
mindSpacePublications,
getUserAuth: () => userAuth,
async sendPublishedPage(
req,
res,
result,
options,
) {
calls.push([
'send-published',
result,
options,
]);
return res.send('published-page');
},
fsApi: {
existsSync() {
return true;
},
statSync() {
return {
isFile: () => true,
};
},
},
resolveUserPublishDir() {
return '/workspace/user-1';
},
isEmbedRequest(query) {
return query.embed === '1';
},
isPublicWorkspaceHtml(value) {
return value?.startsWith('public/');
},
buildPublicRoutePath(ownerId, segments) {
return `/MindSpace/${ownerId}/${segments.join('/')}`;
},
rasterizeThumbnail(svg) {
calls.push(['rasterize', svg]);
return Buffer.from('png');
},
...overrides,
};
attachPortalPublicationRoutes(defaults);
return {
routes,
calls,
urlencodedBody,
setAuthPool(value) {
authPool = value;
},
setMindSpacePages(value) {
mindSpacePages = value;
},
setMindSpacePublications(value) {
mindSpacePublications = value;
},
setUserAuth(value) {
userAuth = value;
},
setPublicResult(value) {
publicResult = value;
},
route(method, routePath) {
return routes.find(
(route) =>
route.method === method &&
route.path === routePath,
);
},
};
}
async function invoke(
setup,
method,
routePath,
request = createRequest(),
) {
const route = setup.route(method, routePath);
assert.ok(
route,
`${method.toUpperCase()} ${routePath}`,
);
const response = createResponse();
let nextCalled = false;
await route.handlers.at(-1)(
request,
response,
() => {
nextCalled = true;
},
);
return { response, nextCalled };
}
test('registers publication route inventory and password middleware in order', () => {
const setup = createSetup();
assert.deepEqual(
setup.routes.map(({ method, path }) => [
method,
path,
]),
[
['use', null],
[
'get',
'/u/:ownerSlug/pages/:urlSlug',
],
['use', '/u/:ownerSlug/public'],
['get', '/u/:ownerSlug'],
[
'post',
'/u/:ownerSlug/pages/:urlSlug',
],
['get', '/s/:token'],
],
);
assert.equal(
setup.routes[4].handlers[0],
setup.urlencodedBody,
);
});
test('public page redirects canonical workspace HTML and preserves viewer metadata', async () => {
const setup = createSetup();
const { response } = await invoke(
setup,
'get',
'/u/:ownerSlug/pages/:urlSlug',
createRequest({
params: {
ownerSlug: 'alice',
urlSlug: 'page',
},
userSession: { id: 'session' },
userToken: 'viewer-token',
}),
);
assert.equal(response.statusCode, 301);
assert.equal(
response.redirectValue,
'/MindSpace/user-1/public/page.html',
);
assert.deepEqual(
setup.calls.find(
(call) => call[0] === 'resolve-public',
),
[
'resolve-public',
'alice',
'page',
'viewer-1',
null,
{
userAgent: 'test-agent',
referrer:
'https://referrer.example',
},
],
);
});
test('raw, embed, and password access preserve direct page delivery', async () => {
const setup = createSetup();
const raw = await invoke(
setup,
'get',
'/u/:ownerSlug/pages/:urlSlug',
createRequest({
params: {
ownerSlug: 'alice',
urlSlug: 'page',
},
query: { view: 'RAW' },
}),
);
assert.equal(raw.response.body, 'published-page');
assert.deepEqual(
setup.calls.at(-1),
[
'send-published',
{
ownerId: 'user-1',
workspaceRelativePath:
'public/page.html',
publication: {
accessMode: 'public',
},
},
{
embed: false,
raw: true,
ownerSlug: 'alice',
},
],
);
const posted = await invoke(
setup,
'post',
'/u/:ownerSlug/pages/:urlSlug',
createRequest({
params: {
ownerSlug: 'alice',
urlSlug: 'page',
},
query: { embed: '1' },
body: {
password: 'password-123',
},
}),
);
assert.equal(
posted.response.body,
'published-page',
);
const resolveCall = setup.calls
.filter(
(call) =>
call[0] === 'resolve-public',
)
.at(-1);
assert.equal(resolveCall[4], 'password-123');
});
test('public page preserves password, login, not-found, and generic errors', async () => {
for (const [
code,
password,
status,
text,
] of [
[
'publication_password_required',
null,
200,
'此页面受密码保护',
],
[
'publication_password_required',
'wrong-password',
403,
'此页面受密码保护',
],
[
'publication_login_required',
null,
401,
'请先登录 TKMind',
],
[
'publication_not_found',
null,
404,
'页面不存在或已下线',
],
[
'unexpected',
null,
500,
'页面加载失败',
],
]) {
const setup = createSetup();
setup.setMindSpacePublications({
async resolvePublic() {
throw Object.assign(
new Error(code),
{ code },
);
},
});
const method = password ? 'post' : 'get';
const result = await invoke(
setup,
method,
'/u/:ownerSlug/pages/:urlSlug',
createRequest({
originalUrl: '/u/alice/pages/page',
params: {
ownerSlug: 'alice',
urlSlug: 'page',
},
body: { password },
}),
);
assert.equal(
result.response.statusCode,
status,
);
assert.match(
String(result.response.body),
new RegExp(text),
);
}
});
test('thumbnail middleware preserves pass-through, lookup, headers, and errors', async () => {
const setup = createSetup();
const middleware = setup.routes[0];
const response = createResponse();
let nextCalled = false;
await middleware.handlers[0](
createRequest({ path: '/other' }),
response,
() => {
nextCalled = true;
},
);
assert.equal(nextCalled, true);
const thumbnailResponse = createResponse();
await middleware.handlers[0](
createRequest({
path:
'/u/alice/pages/page.thumbnail.png',
}),
thumbnailResponse,
() => {},
);
assert.equal(
thumbnailResponse.statusCode,
200,
);
assert.equal(
thumbnailResponse.headers['Content-Type'],
'image/png',
);
assert.equal(
thumbnailResponse.headers['Cache-Control'],
'public, max-age=300',
);
assert.equal(
thumbnailResponse.body.toString(),
'png',
);
setup.setAuthPool(null);
const disabledResponse = createResponse();
await middleware.handlers[0](
createRequest({
path:
'/u/alice/pages/page.thumbnail.png',
}),
disabledResponse,
() => {},
);
assert.equal(
disabledResponse.statusCode,
503,
);
});
test('public asset route preserves owner lookup, path, and file responses', async () => {
const setup = createSetup();
const success = await invoke(
setup,
'use',
'/u/:ownerSlug/public',
createRequest({
path: '/assets/file.txt',
params: { ownerSlug: 'ALICE' },
}),
);
assert.equal(
success.response.sentFile,
'/workspace/user-1/public/assets/file.txt',
);
const queryCall = setup.calls.find(
(call) => call[0] === 'query',
);
assert.deepEqual(queryCall[2], ['alice']);
setup.setAuthPool({
async query() {
return [[]];
},
});
const missing = await invoke(
setup,
'use',
'/u/:ownerSlug/public',
createRequest({
path: '/file.txt',
params: { ownerSlug: 'alice' },
}),
);
assert.equal(missing.response.statusCode, 404);
assert.deepEqual(missing.response.body, {
message: '用户不存在',
});
});
test('homepage and private link preserve headers, viewer, and delivery options', async () => {
const setup = createSetup();
const homepage = await invoke(
setup,
'get',
'/u/:ownerSlug',
createRequest({
params: { ownerSlug: 'alice' },
}),
);
assert.equal(
homepage.response.statusCode,
200,
);
assert.match(
homepage.response.body,
/Alice · MindSpace/,
);
assert.equal(
homepage.response.headers['Cache-Control'],
'public, max-age=60',
);
const privateLink = await invoke(
setup,
'get',
'/s/:token',
createRequest({
params: { token: 'private-token' },
query: {
embed: '1',
view: 'raw',
},
userSession: { id: 'session' },
userToken: 'viewer-token',
}),
);
assert.equal(
privateLink.response.body,
'published-page',
);
assert.deepEqual(
setup.calls.find(
(call) =>
call[0] === 'resolve-private',
),
[
'resolve-private',
'private-token',
'viewer-1',
{
userAgent: 'test-agent',
referrer:
'https://referrer.example',
},
],
);
assert.deepEqual(
setup.calls.at(-1)[2],
{
embed: true,
raw: true,
},
);
});
test('publication HTML helpers preserve escaping and password form action', () => {
const gate =
renderPublicationPasswordGate(
'/u/alice/pages/private',
);
assert.match(
gate,
/action="\/u\/alice\/pages\/private"/,
);
const homepage = renderPublicHomepage({
owner: {
displayName: '<Alice>',
slug: 'alice',
},
pages: [{
publicUrl:
'https://example.test/?a=1&b=2',
templateId: '<template>',
viewCount: 2,
title: '<Title>',
summary: '<Summary>',
publishedAt: '2026-07-24T00:00:00Z',
}],
pageCount: 1,
totalViews: 2,
});
assert.doesNotMatch(homepage, /<Alice>/);
assert.match(homepage, /&lt;Alice&gt;/);
assert.match(
homepage,
/a=1&amp;b=2/,
);
});