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 'viewer'; }, }); 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, 'viewer'); 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 ''; }, async renderAssetPreview(userId, assetId) { calls.push({ kind: 'preview', userId, assetId }); return 'preview'; }, 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, ''); 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, 'preview'); 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 'Landing'; }, }); 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: 'Landing', 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', }); });