Files
memind/server/portal-mindspace-page-publish-routes.test.mjs

590 lines
16 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { attachPortalMindSpacePagePublishRoutes } from './portal-mindspace-page-publish-routes.mjs';
function createRouterRecorder() {
const routes = new Map();
return {
routes,
get(path, handler) {
routes.set(`GET ${path}`, handler);
},
post(path, handler) {
routes.set(`POST ${path}`, handler);
},
};
}
function createResponseRecorder() {
return {
statusCode: 200,
body: undefined,
headers: {},
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
},
set(name, value) {
this.headers[name] = value;
return this;
},
setHeader(name, value) {
this.headers[name] = value;
return this;
},
send(body) {
this.body = body;
return this;
},
};
}
function createRequest(overrides = {}) {
return {
body: {},
params: {
pageId: 'page-1',
publicationId: 'publication-1',
},
currentUser: {
id: 'user-1',
workspaceRoot: '/workspace/user-1',
},
requestId: 'request-1',
ip: '127.0.0.1',
...overrides,
};
}
function createDependencies(overrides = {}) {
const calls = {
data: [],
routeErrors: [],
};
return {
calls,
dependencies: {
env: {},
getMindSpacePages: () => ({}),
getMindSpacePublications: () => ({}),
getMindSpaceAudit: () => null,
getAuthPool: () => ({ id: 'pool' }),
sendData(res, req, data, status = 200) {
calls.data.push({ res, req, data, status });
return res.status(status).json({ data });
},
handleMindSpaceError(res, req, error) {
calls.routeErrors.push({ res, req, error });
return res.status(418).json({ mapped: error.message });
},
...overrides,
},
};
}
test('page publish module preserves route inventory and order', () => {
const api = createRouterRecorder();
attachPortalMindSpacePagePublishRoutes(
api,
createDependencies().dependencies,
);
assert.deepEqual([...api.routes.keys()], [
'GET /mindspace/v1/pages/:pageId/thumbnail',
'POST /mindspace/v1/pages/:pageId/thumbnail/upload',
'POST /mindspace/v1/pages/:pageId/thumbnail/regenerate',
'POST /mindspace/v1/pages/:pageId/rewrite-download-links',
'GET /mindspace/v1/pages/:pageId/preview',
'POST /mindspace/v1/pages/:pageId/preview-draft',
'POST /mindspace/v1/pages/:pageId/publish-check',
'POST /mindspace/v1/pages/:pageId/redact',
'POST /mindspace/v1/pages/:pageId/publish-fix',
'POST /mindspace/v1/pages/:pageId/redacted-copy',
'POST /mindspace/v1/pages/:pageId/publish',
'POST /mindspace/v1/publications/:publicationId/update-status',
'POST /mindspace/v1/publications/:publicationId/offline',
'GET /mindspace/v1/publications/:publicationId/stats',
]);
});
test('thumbnail read and upload preserve headers and payload mapping', async () => {
const received = [];
const api = createRouterRecorder();
const setup = createDependencies({
getMindSpacePages: () => ({
async renderThumbnail(userId, pageId) {
received.push({ kind: 'render', userId, pageId });
return '<svg>page</svg>';
},
async uploadThumbnail(userId, pageId, input) {
received.push({ kind: 'upload', userId, pageId, input });
return { uploaded: true };
},
}),
});
attachPortalMindSpacePagePublishRoutes(api, setup.dependencies);
const thumbnailRes = createResponseRecorder();
await api.routes.get(
'GET /mindspace/v1/pages/:pageId/thumbnail',
)(createRequest(), thumbnailRes);
assert.equal(thumbnailRes.body, '<svg>page</svg>');
assert.equal(
thumbnailRes.headers['Content-Type'],
'image/svg+xml; charset=utf-8',
);
assert.equal(
thumbnailRes.headers['Cache-Control'],
'private, max-age=300',
);
assert.equal(
thumbnailRes.headers['X-Request-Id'],
'request-1',
);
const uploadRes = createResponseRecorder();
await api.routes.get(
'POST /mindspace/v1/pages/:pageId/thumbnail/upload',
)(
createRequest({
body: {
image_base64: 'base64',
mime_type: 'image/png',
title: 'Title',
summary: 'Summary',
html: '<html></html>',
},
}),
uploadRes,
);
assert.deepEqual(received[1].input, {
imageBase64: 'base64',
mimeType: 'image/png',
title: 'Title',
summary: 'Summary',
html: '<html></html>',
});
assert.equal(uploadRes.body.data.uploaded, true);
});
test('thumbnail regeneration preserves AI callback and encryption key', async () => {
const calls = [];
const api = createRouterRecorder();
const setup = createDependencies({
env: { H5_SETTINGS_ENCRYPTION_KEY: 'encryption-key' },
getAuthPool: () => ({ id: 'pool-1' }),
getMindSpacePages: () => ({
async regenerateThumbnail(userId, pageId, input, options) {
calls.push({ userId, pageId, input });
const suggested = await options.suggestCoverMeta({
title: 'Page',
});
return { suggested };
},
}),
async suggestCoverMetaWithAiFn(pool, input) {
calls.push({ pool, input });
return { cover: 'ai' };
},
});
attachPortalMindSpacePagePublishRoutes(api, setup.dependencies);
const res = createResponseRecorder();
await api.routes.get(
'POST /mindspace/v1/pages/:pageId/thumbnail/regenerate',
)(
createRequest({
body: {
html: '<html></html>',
title: 'Title',
summary: 'Summary',
use_ai: true,
instruction: 'bright',
},
}),
res,
);
assert.equal(calls[0].input.useAi, true);
assert.deepEqual(calls[1], {
pool: { id: 'pool-1' },
input: {
title: 'Page',
encryptionKey: 'encryption-key',
},
});
assert.equal(res.body.data.suggested.cover, 'ai');
const missingPoolApi = createRouterRecorder();
const missingPool = createDependencies({
getAuthPool: () => null,
getMindSpacePages: () => ({
async regenerateThumbnail(_userId, _pageId, _input, options) {
return options.suggestCoverMeta({});
},
}),
});
attachPortalMindSpacePagePublishRoutes(
missingPoolApi,
missingPool.dependencies,
);
const missingPoolRes = createResponseRecorder();
await missingPoolApi.routes.get(
'POST /mindspace/v1/pages/:pageId/thumbnail/regenerate',
)(
createRequest({ body: { use_ai: true } }),
missingPoolRes,
);
assert.equal(missingPoolRes.statusCode, 418);
assert.equal(
missingPool.calls.routeErrors[0].error.code,
'llm_not_configured',
);
});
test('download-link rewrite and previews preserve validation, CSP, and draft input', async () => {
const calls = [];
const api = createRouterRecorder();
const setup = createDependencies({
getMindSpacePages: () => ({
async rewriteHtmlDownloadLinksForPage(
userId,
pageId,
content,
) {
calls.push({ kind: 'rewrite', userId, pageId, content });
return '<a>localized</a>';
},
async renderPreview() {
return {
html: '<html>preview</html>',
contentFormat: 'html',
};
},
async renderDraftPreview(userId, pageId, input) {
calls.push({ kind: 'draft', userId, pageId, input });
return {
html: '<html>draft</html>',
contentFormat: 'markdown',
};
},
}),
previewContentSecurityPolicy(format) {
calls.push({ kind: 'csp', format });
return `policy-${format}`;
},
});
attachPortalMindSpacePagePublishRoutes(api, setup.dependencies);
const rewriteRes = createResponseRecorder();
await api.routes.get(
'POST /mindspace/v1/pages/:pageId/rewrite-download-links',
)(
createRequest({ body: { content: ' <a>file</a> ' } }),
rewriteRes,
);
assert.equal(calls[0].content, '<a>file</a>');
assert.equal(rewriteRes.body.data.html, '<a>localized</a>');
const previewRes = createResponseRecorder();
await api.routes.get(
'GET /mindspace/v1/pages/:pageId/preview',
)(createRequest(), previewRes);
assert.equal(
previewRes.headers['Content-Security-Policy'],
'policy-html',
);
assert.equal(previewRes.headers['Cache-Control'], 'private, no-store');
assert.equal(previewRes.headers['X-Content-Type-Options'], 'nosniff');
const draftRes = createResponseRecorder();
await api.routes.get(
'POST /mindspace/v1/pages/:pageId/preview-draft',
)(
createRequest({
body: {
title: 'Draft',
summary: 'Summary',
content: 'Body',
template_id: 'template-1',
},
}),
draftRes,
);
assert.deepEqual(
calls.find((call) => call.kind === 'draft').input,
{
title: 'Draft',
summary: 'Summary',
content: 'Body',
templateId: 'template-1',
},
);
assert.equal(
draftRes.headers['Content-Security-Policy'],
'policy-markdown',
);
const invalidRes = createResponseRecorder();
await api.routes.get(
'POST /mindspace/v1/pages/:pageId/rewrite-download-links',
)(createRequest({ body: { content: ' ' } }), invalidRes);
assert.equal(invalidRes.statusCode, 418);
assert.equal(
setup.calls.routeErrors.at(-1).error.code,
'invalid_page_input',
);
});
test('publish check and page transforms preserve inputs and audits', async () => {
const calls = [];
const audit = [];
const api = createRouterRecorder();
const transformResult = {
page: { id: 'page-1' },
originalScan: { riskLevel: 'medium' },
};
const setup = createDependencies({
getMindSpacePublications: () => ({
async check(userId, pageId, input) {
calls.push({ kind: 'check', userId, pageId, input });
return { allowed: true };
},
}),
getMindSpacePages: () => ({
async redactPage(userId, pageId, input) {
calls.push({ kind: 'redact', userId, pageId, input });
return transformResult;
},
async localizePrivateResources(userId, pageId, input) {
calls.push({ kind: 'fix', userId, pageId, input });
return transformResult;
},
}),
getMindSpaceAudit: () => ({
async write(entry) {
audit.push(entry);
},
}),
});
attachPortalMindSpacePagePublishRoutes(api, setup.dependencies);
const body = {
page_version_id: 'version-1',
expected_version: 2,
access_mode: 'public',
url_slug: 'page',
password: 'secret',
expires_at: '2030-01-01',
title: 'Title',
summary: 'Summary',
content: 'Body',
};
await api.routes.get(
'POST /mindspace/v1/pages/:pageId/publish-check',
)(createRequest({ body }), createResponseRecorder());
assert.deepEqual(calls[0].input, {
pageVersionId: 'version-1',
accessMode: 'public',
urlSlug: 'page',
password: 'secret',
expiresAt: '2030-01-01',
});
for (const route of [
'POST /mindspace/v1/pages/:pageId/redact',
'POST /mindspace/v1/pages/:pageId/publish-fix',
'POST /mindspace/v1/pages/:pageId/redacted-copy',
]) {
await api.routes.get(route)(
createRequest({ body }),
createResponseRecorder(),
);
}
assert.deepEqual(
calls.slice(1).map((call) => call.kind),
['redact', 'fix', 'redact'],
);
assert.deepEqual(
audit.map((entry) => entry.action),
['page.redact', 'page.publish_fix', 'page.redact'],
);
assert.equal(audit[0].riskLevel, 'medium');
});
test('publish preserves publication input, Page Data policy sync, audit, and 201', async () => {
const calls = [];
const api = createRouterRecorder();
const setup = createDependencies({
getMindSpacePublications: () => ({
async publish(userId, pageId, input) {
calls.push({ kind: 'publish', userId, pageId, input });
return {
id: 'publication-1',
accessMode: 'public',
};
},
}),
getMindSpaceAudit: () => ({
async write(entry) {
calls.push({ kind: 'audit', entry });
},
}),
syncPageDataPolicyAccessModeFn(
workspaceRoot,
pageId,
accessMode,
userId,
) {
calls.push({
kind: 'sync-policy',
workspaceRoot,
pageId,
accessMode,
userId,
});
return { pageId, accessMode };
},
async upsertPageDataPolicyIndexFn(pool, policy) {
calls.push({ kind: 'upsert-policy', pool, policy });
},
});
attachPortalMindSpacePagePublishRoutes(api, setup.dependencies);
const res = createResponseRecorder();
await api.routes.get('POST /mindspace/v1/pages/:pageId/publish')(
createRequest({
body: {
page_version_id: 'version-1',
access_mode: 'public',
url_slug: 'page',
password: 'secret',
expires_at: '2030-01-01',
acknowledged_finding_ids: ['finding-1'],
},
}),
res,
);
assert.deepEqual(calls[0].input, {
pageVersionId: 'version-1',
accessMode: 'public',
urlSlug: 'page',
password: 'secret',
expiresAt: '2030-01-01',
acknowledgedFindingIds: ['finding-1'],
});
assert.deepEqual(calls.map((call) => call.kind), [
'publish',
'sync-policy',
'upsert-policy',
'audit',
]);
assert.equal(res.statusCode, 201);
});
test('publication status, offline, and stats preserve forwarding and audits', async () => {
const calls = [];
const api = createRouterRecorder();
const setup = createDependencies({
getMindSpacePublications: () => ({
async updatePublicationStatus(userId, publicationId, input) {
calls.push({
kind: 'status',
userId,
publicationId,
input,
});
return { id: publicationId, accessMode: input.accessMode };
},
async offline(userId, publicationId) {
calls.push({ kind: 'offline', userId, publicationId });
return { id: publicationId, status: 'offline' };
},
async getStats(userId, publicationId) {
calls.push({ kind: 'stats', userId, publicationId });
return { views: 8 };
},
}),
getMindSpaceAudit: () => ({
async write(entry) {
calls.push({ kind: 'audit', entry });
},
}),
});
attachPortalMindSpacePagePublishRoutes(api, setup.dependencies);
await api.routes.get(
'POST /mindspace/v1/publications/:publicationId/update-status',
)(
createRequest({
body: {
access_mode: 'password',
expires_at: '2030-01-01',
},
}),
createResponseRecorder(),
);
assert.deepEqual(calls[0].input, {
accessMode: 'password',
expiresAt: '2030-01-01',
});
assert.equal(calls[1].entry.action, 'publication.status_updated');
await api.routes.get(
'POST /mindspace/v1/publications/:publicationId/offline',
)(createRequest(), createResponseRecorder());
assert.equal(calls[2].kind, 'offline');
assert.equal(calls[3].entry.action, 'page.offline');
const statsRes = createResponseRecorder();
await api.routes.get(
'GET /mindspace/v1/publications/:publicationId/stats',
)(createRequest(), statsRes);
assert.equal(calls[4].kind, 'stats');
assert.equal(statsRes.body.data.views, 8);
});
test('page publish routes preserve unavailable and mapped errors', async () => {
const unavailableApi = createRouterRecorder();
const unavailable = createDependencies({
getMindSpacePages: () => null,
getMindSpacePublications: () => null,
});
attachPortalMindSpacePagePublishRoutes(
unavailableApi,
unavailable.dependencies,
);
const thumbnailRes = createResponseRecorder();
await unavailableApi.routes.get(
'GET /mindspace/v1/pages/:pageId/thumbnail',
)(createRequest(), thumbnailRes);
assert.equal(thumbnailRes.statusCode, 503);
const statsRes = createResponseRecorder();
await unavailableApi.routes.get(
'GET /mindspace/v1/publications/:publicationId/stats',
)(createRequest(), statsRes);
assert.equal(statsRes.statusCode, 503);
const failureApi = createRouterRecorder();
const failure = createDependencies({
getMindSpacePages: () => ({
async renderThumbnail() {
throw new Error('thumbnail failed');
},
}),
});
attachPortalMindSpacePagePublishRoutes(
failureApi,
failure.dependencies,
);
const failureRes = createResponseRecorder();
await failureApi.routes.get(
'GET /mindspace/v1/pages/:pageId/thumbnail',
)(createRequest(), failureRes);
assert.equal(failureRes.statusCode, 418);
assert.equal(
failure.calls.routeErrors[0].error.message,
'thumbnail failed',
);
});