645 lines
18 KiB
JavaScript
645 lines
18 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { attachPortalMindSpaceAssetRoutes } from './portal-mindspace-asset-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);
|
|
},
|
|
delete(path, handler) {
|
|
routes.set(`DELETE ${path}`, handler);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createResponseRecorder() {
|
|
return {
|
|
statusCode: 200,
|
|
body: undefined,
|
|
headers: {},
|
|
filePath: undefined,
|
|
mimeType: undefined,
|
|
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;
|
|
},
|
|
type(value) {
|
|
this.mimeType = value;
|
|
return this;
|
|
},
|
|
send(body) {
|
|
this.body = body;
|
|
return this;
|
|
},
|
|
sendFile(filePath) {
|
|
this.filePath = filePath;
|
|
return this;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRequest(overrides = {}) {
|
|
const headers = Object.fromEntries(
|
|
Object.entries(overrides.headers ?? {}).map(([key, value]) => [
|
|
key.toLowerCase(),
|
|
value,
|
|
]),
|
|
);
|
|
return {
|
|
body: {},
|
|
query: {},
|
|
params: {},
|
|
currentUser: { id: 'user-1' },
|
|
userSession: null,
|
|
ip: '127.0.0.1',
|
|
requestId: 'request-1',
|
|
headers,
|
|
get(name) {
|
|
return headers[String(name).toLowerCase()];
|
|
},
|
|
...overrides,
|
|
headers,
|
|
};
|
|
}
|
|
|
|
function createDependencies(overrides = {}) {
|
|
const calls = {
|
|
data: [],
|
|
mapped: [],
|
|
};
|
|
return {
|
|
calls,
|
|
dependencies: {
|
|
ensureMindSpaceEnabled: () => true,
|
|
sendData(res, req, data, status = 200) {
|
|
calls.data.push({ res, req, data, status });
|
|
return res.status(status).json({ data });
|
|
},
|
|
handleMindSpaceError(res, req, error) {
|
|
calls.mapped.push({ res, req, error });
|
|
return res.status(418).json({ mapped: error.message });
|
|
},
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
test('MindSpace asset module preserves route inventory and order', () => {
|
|
const api = createRouterRecorder();
|
|
attachPortalMindSpaceAssetRoutes(api);
|
|
assert.deepEqual([...api.routes.keys()], [
|
|
'GET /mindspace/v1/authorize-image',
|
|
'GET /mindspace/v1/assets/:assetId/download',
|
|
'GET /mindspace/v1/assets/:assetId/thumbnail',
|
|
'GET /mindspace/v1/assets/:assetId/preview',
|
|
'POST /mindspace/v1/pages/from-asset',
|
|
'DELETE /mindspace/v1/assets/:assetId',
|
|
]);
|
|
});
|
|
|
|
test('authorize image preserves gates, query, cache policy, and expiry', async () => {
|
|
const rows = [];
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
getMindSpacePublications: () => ({}),
|
|
getDatabasePool: () => ({
|
|
async query(sql, params) {
|
|
assert.match(sql, /h5_publication_asset_refs/);
|
|
assert.deepEqual(params, ['asset-1']);
|
|
return [rows];
|
|
},
|
|
}),
|
|
now: () => 1_000,
|
|
});
|
|
attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies);
|
|
const handler = api.routes.get('GET /mindspace/v1/authorize-image');
|
|
|
|
const missingRes = createResponseRecorder();
|
|
await handler(createRequest({ query: {} }), missingRes);
|
|
assert.equal(missingRes.statusCode, 400);
|
|
|
|
rows.splice(0, rows.length, { access_mode: 'public', expires_at: null });
|
|
const publicRes = createResponseRecorder();
|
|
await handler(createRequest({ query: { asset_id: 'asset-1' } }), publicRes);
|
|
assert.equal(publicRes.statusCode, 200);
|
|
assert.equal(
|
|
publicRes.headers['Cache-Control'],
|
|
'public, max-age=31536000, immutable',
|
|
);
|
|
|
|
rows.splice(0, rows.length, {
|
|
access_mode: 'time_limited',
|
|
expires_at: 2_000,
|
|
});
|
|
const timedRes = createResponseRecorder();
|
|
await handler(createRequest({ query: { asset_id: 'asset-1' } }), timedRes);
|
|
assert.equal(timedRes.statusCode, 200);
|
|
assert.equal(timedRes.headers['Cache-Control'], 'public, max-age=60');
|
|
|
|
rows.splice(0, rows.length, {
|
|
access_mode: 'time_limited',
|
|
expires_at: 999,
|
|
});
|
|
const expiredRes = createResponseRecorder();
|
|
await handler(createRequest({ query: { asset_id: 'asset-1' } }), expiredRes);
|
|
assert.equal(expiredRes.statusCode, 403);
|
|
|
|
rows.splice(0);
|
|
const missingRefRes = createResponseRecorder();
|
|
await handler(
|
|
createRequest({ query: { asset_id: 'asset-1' } }),
|
|
missingRefRes,
|
|
);
|
|
assert.equal(missingRefRes.statusCode, 403);
|
|
|
|
const unavailableApi = createRouterRecorder();
|
|
attachPortalMindSpaceAssetRoutes(unavailableApi);
|
|
const unavailableRes = createResponseRecorder();
|
|
await unavailableApi.routes.get('GET /mindspace/v1/authorize-image')(
|
|
createRequest({ query: { asset_id: 'asset-1' } }),
|
|
unavailableRes,
|
|
);
|
|
assert.equal(unavailableRes.statusCode, 503);
|
|
});
|
|
|
|
test('authorize image preserves internal error response and logging', async () => {
|
|
const logs = [];
|
|
const api = createRouterRecorder();
|
|
attachPortalMindSpaceAssetRoutes(api, {
|
|
getMindSpacePublications: () => ({}),
|
|
getDatabasePool: () => ({
|
|
async query() {
|
|
throw new Error('query failed');
|
|
},
|
|
}),
|
|
logger: {
|
|
error(...args) {
|
|
logs.push(args);
|
|
},
|
|
},
|
|
});
|
|
const res = createResponseRecorder();
|
|
await api.routes.get('GET /mindspace/v1/authorize-image')(
|
|
createRequest({ query: { asset_id: 'asset-1' } }),
|
|
res,
|
|
);
|
|
assert.equal(res.statusCode, 500);
|
|
assert.deepEqual(res.body, { error: 'Internal server error' });
|
|
assert.deepEqual(logs[0], ['[authorize-image]', 'query failed']);
|
|
});
|
|
|
|
test('authenticated download preserves audit, headers, and file response', async () => {
|
|
const audit = [];
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
getMindSpaceAssets: () => ({
|
|
async readAsset(userId, assetId) {
|
|
assert.equal(userId, 'user-1');
|
|
assert.equal(assetId, 'asset-1');
|
|
return {
|
|
asset: {
|
|
filename: '报告 1.pdf',
|
|
mimeType: 'application/pdf',
|
|
riskLevel: 'low',
|
|
},
|
|
path: '/tmp/report.pdf',
|
|
};
|
|
},
|
|
}),
|
|
getMindSpaceAudit: () => ({
|
|
async write(entry) {
|
|
audit.push(entry);
|
|
},
|
|
}),
|
|
});
|
|
attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies);
|
|
const res = createResponseRecorder();
|
|
await api.routes.get('GET /mindspace/v1/assets/:assetId/download')(
|
|
createRequest({
|
|
params: { assetId: 'asset-1' },
|
|
query: {},
|
|
ip: '10.0.0.1',
|
|
}),
|
|
res,
|
|
);
|
|
assert.equal(res.mimeType, 'application/pdf');
|
|
assert.equal(res.filePath, '/tmp/report.pdf');
|
|
assert.equal(
|
|
res.headers['Content-Disposition'],
|
|
"attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A%201.pdf",
|
|
);
|
|
assert.equal(res.headers['X-Request-Id'], 'request-1');
|
|
assert.deepEqual(audit[0], {
|
|
userId: 'user-1',
|
|
action: 'asset.download',
|
|
objectType: 'asset',
|
|
objectId: 'asset-1',
|
|
ip: '10.0.0.1',
|
|
riskLevel: 'low',
|
|
detail: undefined,
|
|
});
|
|
});
|
|
|
|
test('image download preserves inline viewer response', async () => {
|
|
const viewerCalls = [];
|
|
const api = createRouterRecorder();
|
|
attachPortalMindSpaceAssetRoutes(api, {
|
|
ensureMindSpaceEnabled: () => true,
|
|
getMindSpaceAssets: () => ({
|
|
async readAsset() {
|
|
return {
|
|
asset: {
|
|
filename: 'cover.png',
|
|
displayName: 'Cover',
|
|
mimeType: 'image/png',
|
|
riskLevel: 'none',
|
|
},
|
|
path: '/tmp/cover.png',
|
|
};
|
|
},
|
|
}),
|
|
wantsImageViewer(req) {
|
|
viewerCalls.push(req);
|
|
return true;
|
|
},
|
|
renderImageViewer(input) {
|
|
viewerCalls.push(input);
|
|
return '<html>viewer</html>';
|
|
},
|
|
});
|
|
const req = createRequest({
|
|
params: { assetId: 'asset/image' },
|
|
query: { inline: '1' },
|
|
});
|
|
const res = createResponseRecorder();
|
|
await api.routes.get('GET /mindspace/v1/assets/:assetId/download')(req, res);
|
|
assert.equal(res.body, '<html>viewer</html>');
|
|
assert.equal(res.headers['Content-Type'], 'text/html; charset=utf-8');
|
|
assert.equal(res.headers['Cache-Control'], 'private, no-store');
|
|
assert.deepEqual(viewerCalls[1], {
|
|
asset: {
|
|
filename: 'cover.png',
|
|
displayName: 'Cover',
|
|
mimeType: 'image/png',
|
|
riskLevel: 'none',
|
|
},
|
|
downloadUrl:
|
|
'/api/mindspace/v1/assets/asset%2Fimage/download?inline=1',
|
|
});
|
|
});
|
|
|
|
test('anonymous download preserves publication, signed-token, referrer, and denial rules', async () => {
|
|
const audit = [];
|
|
const rows = [];
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
getMindSpaceAssets: () => ({
|
|
async readPublicAsset(assetId) {
|
|
return {
|
|
asset: {
|
|
filename: `${assetId}.png`,
|
|
mimeType: 'image/png',
|
|
riskLevel: 'none',
|
|
},
|
|
path: `/tmp/${assetId}.png`,
|
|
};
|
|
},
|
|
}),
|
|
getDatabasePool: () => ({
|
|
async query() {
|
|
return [rows];
|
|
},
|
|
}),
|
|
getInternalAgentSecret: () => 'secret-1',
|
|
verifyAssetToken(assetId, token, secret) {
|
|
return assetId === 'asset-1' &&
|
|
token === 'valid-token' &&
|
|
secret === 'secret-1';
|
|
},
|
|
resolveRequestOrigin: () => 'https://portal.example.com',
|
|
getMindSpaceAudit: () => ({
|
|
async write(entry) {
|
|
audit.push(entry);
|
|
},
|
|
}),
|
|
});
|
|
attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies);
|
|
const handler = api.routes.get('GET /mindspace/v1/assets/:assetId/download');
|
|
|
|
rows.splice(0, rows.length, { access_mode: 'public' });
|
|
await handler(
|
|
createRequest({
|
|
currentUser: null,
|
|
params: { assetId: 'asset-public' },
|
|
query: {},
|
|
}),
|
|
createResponseRecorder(),
|
|
);
|
|
assert.deepEqual(audit.at(-1).detail, { accessMode: 'public' });
|
|
|
|
rows.splice(0);
|
|
await handler(
|
|
createRequest({
|
|
currentUser: null,
|
|
params: { assetId: 'asset-1' },
|
|
query: { public_token: 'valid-token' },
|
|
}),
|
|
createResponseRecorder(),
|
|
);
|
|
assert.deepEqual(audit.at(-1).detail, {
|
|
accessMode: 'signed-public-token',
|
|
});
|
|
|
|
await handler(
|
|
createRequest({
|
|
currentUser: null,
|
|
params: { assetId: 'asset-referrer' },
|
|
query: {},
|
|
headers: {
|
|
referer: 'https://portal.example.com/MindSpace/user/public/page.html',
|
|
},
|
|
}),
|
|
createResponseRecorder(),
|
|
);
|
|
assert.deepEqual(audit.at(-1).detail, {
|
|
accessMode: 'public-page-referrer',
|
|
});
|
|
|
|
const deniedRes = createResponseRecorder();
|
|
await handler(
|
|
createRequest({
|
|
currentUser: null,
|
|
params: { assetId: 'asset-denied' },
|
|
query: {},
|
|
}),
|
|
deniedRes,
|
|
);
|
|
assert.equal(deniedRes.statusCode, 401);
|
|
assert.deepEqual(deniedRes.body, { message: '未授权,请重新登录' });
|
|
});
|
|
|
|
test('thumbnail, preview, and delete preserve service calls and response metadata', async () => {
|
|
const calls = [];
|
|
const audit = [];
|
|
const assets = {
|
|
async renderAssetThumbnail(userId, assetId) {
|
|
calls.push({ kind: 'thumbnail', userId, assetId });
|
|
return '<svg />';
|
|
},
|
|
async renderAssetPreview(userId, assetId) {
|
|
calls.push({ kind: 'preview', userId, assetId });
|
|
return '<html>preview</html>';
|
|
},
|
|
async deleteAsset(userId, assetId) {
|
|
calls.push({ kind: 'delete', userId, assetId });
|
|
return { id: assetId, deleted: true };
|
|
},
|
|
};
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
getMindSpaceAssets: () => assets,
|
|
getMindSpaceAudit: () => ({
|
|
async write(entry) {
|
|
audit.push(entry);
|
|
},
|
|
}),
|
|
});
|
|
attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies);
|
|
const req = createRequest({
|
|
params: { assetId: 'asset-1' },
|
|
ip: '10.0.0.2',
|
|
});
|
|
|
|
const thumbnailRes = createResponseRecorder();
|
|
await api.routes.get('GET /mindspace/v1/assets/:assetId/thumbnail')(
|
|
req,
|
|
thumbnailRes,
|
|
);
|
|
assert.equal(thumbnailRes.body, '<svg />');
|
|
assert.equal(
|
|
thumbnailRes.headers['Content-Type'],
|
|
'image/svg+xml; charset=utf-8',
|
|
);
|
|
|
|
const previewRes = createResponseRecorder();
|
|
await api.routes.get('GET /mindspace/v1/assets/:assetId/preview')(
|
|
req,
|
|
previewRes,
|
|
);
|
|
assert.equal(previewRes.body, '<html>preview</html>');
|
|
assert.equal(previewRes.headers['Cache-Control'], 'private, no-store');
|
|
|
|
await api.routes.get('DELETE /mindspace/v1/assets/:assetId')(
|
|
req,
|
|
createResponseRecorder(),
|
|
);
|
|
assert.deepEqual(calls, [
|
|
{ kind: 'thumbnail', userId: 'user-1', assetId: 'asset-1' },
|
|
{ kind: 'preview', userId: 'user-1', assetId: 'asset-1' },
|
|
{ kind: 'delete', userId: 'user-1', assetId: 'asset-1' },
|
|
]);
|
|
assert.deepEqual(audit, [{
|
|
userId: 'user-1',
|
|
action: 'asset.delete',
|
|
objectType: 'asset',
|
|
objectId: 'asset-1',
|
|
ip: '10.0.0.2',
|
|
}]);
|
|
assert.deepEqual(dependencies.calls.data[0].data, {
|
|
id: 'asset-1',
|
|
deleted: true,
|
|
});
|
|
});
|
|
|
|
test('from-asset preserves existing-page reuse and HTML page creation', async () => {
|
|
const existingApi = createRouterRecorder();
|
|
const existingDependencies = createDependencies({
|
|
getMindSpaceAssets: () => ({}),
|
|
getMindSpacePages: () => ({
|
|
async findPageBySourceAsset(userId, assetId) {
|
|
assert.equal(userId, 'user-1');
|
|
assert.equal(assetId, 'asset-1');
|
|
return { id: 'page-existing', categoryCode: null };
|
|
},
|
|
}),
|
|
});
|
|
attachPortalMindSpaceAssetRoutes(
|
|
existingApi,
|
|
existingDependencies.dependencies,
|
|
);
|
|
await existingApi.routes.get('POST /mindspace/v1/pages/from-asset')(
|
|
createRequest({ body: { asset_id: 'asset-1' } }),
|
|
createResponseRecorder(),
|
|
);
|
|
assert.deepEqual(existingDependencies.calls.data[0].data, {
|
|
kind: 'page',
|
|
categoryCode: 'draft',
|
|
page: { id: 'page-existing', categoryCode: null },
|
|
});
|
|
|
|
const calls = [];
|
|
const createApi = createRouterRecorder();
|
|
const createDependenciesResult = createDependencies({
|
|
getMindSpaceAssets: () => ({
|
|
async readAsset(userId, assetId) {
|
|
calls.push({ kind: 'read-asset', userId, assetId });
|
|
return {
|
|
asset: {
|
|
id: 'asset-2',
|
|
mimeType: 'text/html',
|
|
originalFilename: 'landing.html',
|
|
displayName: 'Landing',
|
|
categoryCode: 'document',
|
|
},
|
|
path: '/tmp/landing.html',
|
|
};
|
|
},
|
|
}),
|
|
getMindSpacePages: () => ({
|
|
async findPageBySourceAsset() {
|
|
return null;
|
|
},
|
|
async findPageByRelativePath(userId, relativePath) {
|
|
calls.push({ kind: 'find-path', userId, relativePath });
|
|
return null;
|
|
},
|
|
async createFromChat(userId, input, metadata) {
|
|
calls.push({ kind: 'create', userId, input, metadata });
|
|
return { id: 'page-2', title: input.title };
|
|
},
|
|
}),
|
|
normalizeWorkspacePath(value) {
|
|
calls.push({ kind: 'normalize', value });
|
|
return value;
|
|
},
|
|
async readFile(path, encoding) {
|
|
calls.push({ kind: 'read-file', path, encoding });
|
|
return '<html>Landing</html>';
|
|
},
|
|
});
|
|
attachPortalMindSpaceAssetRoutes(
|
|
createApi,
|
|
createDependenciesResult.dependencies,
|
|
);
|
|
const res = createResponseRecorder();
|
|
await createApi.routes.get('POST /mindspace/v1/pages/from-asset')(
|
|
createRequest({
|
|
body: {
|
|
asset_id: 'asset-2',
|
|
title: 'Custom title',
|
|
summary: 'Summary',
|
|
template_id: 'template-1',
|
|
},
|
|
}),
|
|
res,
|
|
);
|
|
assert.equal(res.statusCode, 201);
|
|
assert.deepEqual(res.body, {
|
|
data: {
|
|
kind: 'page',
|
|
categoryCode: 'draft',
|
|
page: { id: 'page-2', title: 'Custom title' },
|
|
},
|
|
});
|
|
assert.deepEqual(calls.find((call) => call.kind === 'create'), {
|
|
kind: 'create',
|
|
userId: 'user-1',
|
|
input: {
|
|
title: 'Custom title',
|
|
summary: 'Summary',
|
|
content: '<html>Landing</html>',
|
|
contentFormat: 'html',
|
|
templateId: 'template-1',
|
|
categoryCode: 'draft',
|
|
},
|
|
metadata: {
|
|
assetId: 'asset-2',
|
|
snapshot: {
|
|
source_asset_id: 'asset-2',
|
|
source_category: 'document',
|
|
content_mode: 'html',
|
|
relative_path: 'public/landing.html',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('asset route errors preserve denied audit and shared mapping', async () => {
|
|
const failure = Object.assign(new Error('blocked'), {
|
|
code: 'security_risk_blocked',
|
|
});
|
|
const audit = [];
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
getMindSpaceAssets: () => ({
|
|
async readAsset() {
|
|
throw failure;
|
|
},
|
|
async renderAssetThumbnail() {
|
|
throw failure;
|
|
},
|
|
async renderAssetPreview() {
|
|
throw failure;
|
|
},
|
|
async deleteAsset() {
|
|
throw failure;
|
|
},
|
|
}),
|
|
getMindSpacePages: () => ({
|
|
async findPageBySourceAsset() {
|
|
throw failure;
|
|
},
|
|
}),
|
|
getMindSpaceAudit: () => ({
|
|
async write(entry) {
|
|
audit.push(entry);
|
|
},
|
|
}),
|
|
});
|
|
attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies);
|
|
|
|
for (const route of [
|
|
'GET /mindspace/v1/assets/:assetId/download',
|
|
'GET /mindspace/v1/assets/:assetId/thumbnail',
|
|
'GET /mindspace/v1/assets/:assetId/preview',
|
|
'POST /mindspace/v1/pages/from-asset',
|
|
'DELETE /mindspace/v1/assets/:assetId',
|
|
]) {
|
|
await api.routes.get(route)(
|
|
createRequest({
|
|
params: { assetId: 'asset-1' },
|
|
body: { asset_id: 'asset-1' },
|
|
}),
|
|
createResponseRecorder(),
|
|
);
|
|
}
|
|
assert.equal(dependencies.calls.mapped.length, 5);
|
|
assert.deepEqual(audit[0], {
|
|
userId: 'user-1',
|
|
action: 'asset.download',
|
|
objectType: 'asset',
|
|
objectId: 'asset-1',
|
|
ip: '127.0.0.1',
|
|
result: 'denied',
|
|
riskLevel: 'high',
|
|
});
|
|
});
|