667 lines
14 KiB
JavaScript
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, /<Alice>/);
|
|
assert.match(
|
|
homepage,
|
|
/a=1&b=2/,
|
|
);
|
|
});
|