import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import sharp from 'sharp'; import { assetInternals, createAssetService, validateUploadRequest } from './mindspace-assets.mjs'; import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs'; function createMockPool(state) { return { async getConnection() { return { async beginTransaction() {}, async commit() {}, async rollback() {}, release() {}, async query(sql, params = []) { if (sql.includes('FROM h5_space_categories') && sql.includes('FOR UPDATE')) { const category = state.categories.find( (item) => item.id === params[0] && item.user_id === params[1], ); return [category ? [category] : []]; } if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { const space = state.spaces.find( (item) => item.id === params[0] && item.user_id === params[1], ); return [space ? [space] : []]; } if (sql.includes('INSERT INTO h5_upload_sessions')) { state.uploads.push({ id: params[0], user_id: params[1], space_id: params[2], category_id: params[3], filename: params[4], expected_size: params[5], reserved_bytes: params[7], temporary_storage_key: params[8], status: 'reserved', expires_at: params[9], source_session_id: params[11] ?? null, source_message_id: params[12] ?? null, }); return [[]]; } if (sql.includes('UPDATE h5_user_spaces') && sql.includes('reserved_bytes = reserved_bytes +')) { const space = state.spaces.find((item) => item.id === params[2]); space.reserved_bytes += params[0]; return [[]]; } if (sql.includes('FROM h5_upload_sessions') && sql.includes('WHERE id = ?')) { const upload = state.uploads.find( (item) => item.id === params[0] && item.user_id === params[1], ); return [upload ? [upload] : []]; } if (sql.includes('UPDATE h5_upload_sessions') && sql.includes("status = 'uploaded'")) { const upload = state.uploads.find( (item) => item.id === params[3] && item.user_id === params[4], ); upload.actual_size = params[0]; upload.detected_mime_type = params[1]; upload.checksum = params[2]; upload.status = 'uploaded'; return [[]]; } if (sql.includes('FROM h5_upload_sessions u') && sql.includes('FOR UPDATE')) { if (sql.includes('u.source_message_id = ?')) { const rows = state.uploads .filter( (item) => item.user_id === params[0] && item.source_message_id === params[1] && item.status === 'completed' && !item.source_session_id, ) .map((upload) => { const asset = state.assets.find((item) => item.id === upload.completed_asset_id); const version = state.versions.find((item) => item.id === asset?.current_version_id); const category = state.categories.find((item) => item.id === asset?.category_id); return { ...upload, asset_id: asset.id, asset_mime_type: asset.mime_type, asset_original_filename: asset.original_filename, asset_display_name: asset.display_name, asset_size_bytes: asset.size_bytes, asset_updated_at: asset.updated_at ?? 1, asset_storage_key: version.storage_key, category_code: category?.category_code ?? 'public', }; }); return [rows]; } const upload = state.uploads.find( (item) => item.id === params[0] && item.user_id === params[1], ); if (!upload) return [[]]; const category = state.categories.find((item) => item.id === upload.category_id); return [[{ ...upload, category_code: category.category_code }]]; } if (sql.includes('INSERT INTO h5_assets')) { state.assets.push({ id: params[0], user_id: params[1], category_id: params[3], status: params[13], risk_level: params[11], current_version_id: params[8], original_filename: params[6], display_name: params[7], mime_type: params[5], size_bytes: params[9], }); return [[]]; } if (sql.includes('INSERT INTO h5_asset_versions')) { state.versions.push({ id: params[0], asset_id: params[1], storage_key: params[2], scan_status: params[8], }); return [[]]; } if (sql.includes('UPDATE h5_user_spaces') && sql.includes('used_bytes = used_bytes +')) { const space = state.spaces.find((item) => item.id === params[3]); space.reserved_bytes -= params[0]; space.used_bytes += params[1]; return [[]]; } if (sql.includes("SET status = 'completed'")) { const upload = state.uploads.find( (item) => item.id === params[2] && item.user_id === params[3], ); upload.status = 'completed'; upload.completed_asset_id = params[1]; return [[]]; } if (sql.includes('JOIN h5_asset_versions v')) { if (sql.includes('u.source_message_id = ?')) { const rows = state.uploads .filter( (item) => item.user_id === params[0] && item.source_message_id === params[1] && item.status === 'completed' && !item.source_session_id, ) .map((upload) => { const asset = state.assets.find((item) => item.id === upload.completed_asset_id); const version = state.versions.find((item) => item.id === asset?.current_version_id); const category = state.categories.find((item) => item.id === asset?.category_id); return { ...upload, asset_id: asset.id, asset_mime_type: asset.mime_type, asset_original_filename: asset.original_filename, asset_display_name: asset.display_name, asset_size_bytes: asset.size_bytes, asset_updated_at: asset.updated_at ?? 1, asset_storage_key: version.storage_key, category_code: category?.category_code ?? 'public', }; }); return [rows]; } const asset = state.assets.find( (item) => item.id === params[0] && item.user_id === params[1], ); if (!asset) return [[]]; const version = state.versions.find((item) => item.id === asset.current_version_id); const category = state.categories.find((item) => item.id === asset.category_id); return [[ { ...asset, category_code: category?.category_code ?? 'oa', storage_key: version.storage_key, scan_status: version.scan_status, category_id: category?.id, display_name: asset.original_filename, asset_type: 'file', visibility: 'private', source_type: 'upload', checksum: 'abc', created_at: 1, updated_at: 1, }, ]]; } if (sql.includes('SET source_session_id = ?')) { state.uploads .filter( (item) => item.user_id === params[1] && item.source_message_id === params[2] && item.status === 'completed' && !item.source_session_id, ) .forEach((item) => { item.source_session_id = params[0]; }); return [[]]; } if (sql.includes("status IN ('reserved', 'uploaded') AND expires_at")) { return [state.uploads.filter((item) => item.expires_at <= params[0])]; } return [[]]; }, }; }, async query(sql, params = []) { if (sql.includes('FROM h5_upload_sessions') && sql.includes('WHERE id = ?')) { const upload = state.uploads.find( (item) => item.id === params[0] && item.user_id === params[1], ); return [upload ? [upload] : []]; } if (sql.includes('UPDATE h5_upload_sessions') && sql.includes("status = 'uploaded'")) { const upload = state.uploads.find( (item) => item.id === params[3] && item.user_id === params[4], ); upload.actual_size = params[0]; upload.detected_mime_type = params[1]; upload.checksum = params[2]; upload.status = 'uploaded'; return [[]]; } return [[]]; }, }; } test('validateUploadRequest rejects traversal filenames', () => { assert.throws( () => validateUploadRequest({ filename: '../secret.pdf', sizeBytes: 10 }), (error) => error.code === 'invalid_filename', ); }); test('validateUploadRequest allows 30MB document uploads by default', () => { assert.doesNotThrow(() => validateUploadRequest({ filename: 'handbook.pdf', sizeBytes: DEFAULT_MAX_FILE_BYTES, }), ); assert.throws( () => validateUploadRequest({ filename: 'handbook.pdf', sizeBytes: DEFAULT_MAX_FILE_BYTES + 1, }), (error) => error.code === 'file_too_large', ); }); test('validateUploadRequest keeps image uploads below the image-specific limit', () => { assert.doesNotThrow(() => validateUploadRequest({ filename: 'cover.jpg', sizeBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES, }), ); assert.throws( () => validateUploadRequest({ filename: 'cover.jpg', sizeBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES + 1, }), (error) => error.code === 'file_too_large', ); }); test('completeUpload marks safe files ready and blocks script payloads', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const state = { categories: [ { id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }, { id: 'cat-2', user_id: 'user-2', space_id: 'space-2', category_code: 'oa' }, ], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 5 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], uploads: [], assets: [], versions: [], }; let nextId = 0; const service = createAssetService(createMockPool(state), { storageRoot, idFactory: () => `id-${++nextId}`, }); const payload = Buffer.from(''); const upload = await service.createUpload('user-1', { categoryId: 'cat-1', filename: 'note.txt', sizeBytes: payload.length, }); assert.match(state.uploads[0].temporary_storage_key, /^users\/user-1\/temp\//); await service.writeUploadContent('user-1', upload.id, payload); const asset = await service.completeUpload('user-1', upload.id); assert.equal(asset.status, 'quarantined'); assert.equal(asset.scanStatus, 'blocked'); }); test('createUpload stores optional source session metadata', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const state = { categories: [{ id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 5 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], uploads: [], assets: [], versions: [], }; const service = createAssetService(createMockPool(state), { storageRoot, idFactory: () => 'upload-1', }); await service.createUpload('user-1', { categoryId: 'cat-1', filename: 'note.txt', sizeBytes: 12, sourceSessionId: 'session-1', sourceMessageId: 'message-1', }); assert.equal(state.uploads[0].source_session_id, 'session-1'); assert.equal(state.uploads[0].source_message_id, 'message-1'); }); test('registerUploadArtifactForConversation records uploaded input artifacts', async () => { const calls = []; const registry = { async ensurePackage(input) { calls.push(['ensurePackage', input]); return { id: 'cp_session-1' }; }, async recordArtifact(input) { calls.push(['recordArtifact', input]); return input; }, async writeManifestForSession(input) { calls.push(['writeManifestForSession', input]); return { storageKey: 'users/user-1/conversations/session-1/manifest.json' }; }, }; const result = await assetInternals.registerUploadArtifactForConversation({ registry, userId: 'user-1', upload: { filename: 'cover.png', source_session_id: 'session-1', source_message_id: 'message-1', }, asset: { displayName: 'cover.png', filename: 'cover.png', mimeType: 'image/png', sizeBytes: 123, publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/images/cover.png', }, assetId: 'asset-1', storageKey: 'users/user-1/images/2026-07-02/cover.png', canonicalUrl: 'https://m.tkmind.cn/MindSpace/user-1/images/cover.png', now: 1000, }); assert.equal(result.artifactKind, 'input_image'); assert.equal(result.role, 'user'); assert.equal(result.messageId, 'message-1'); assert.equal(result.canonicalUrl, 'https://m.tkmind.cn/MindSpace/user-1/images/cover.png'); assert.deepEqual(calls[2], [ 'writeManifestForSession', { userId: 'user-1', sessionId: 'session-1' }, ]); }); test('registerUploadArtifactForConversation skips uploads without source session', async () => { assert.equal( await assetInternals.registerUploadArtifactForConversation({ registry: { ensurePackage: async () => assert.fail('should not be called') }, userId: 'user-1', upload: { filename: 'note.txt' }, asset: { mimeType: 'text/plain' }, assetId: 'asset-1', now: 1000, }), null, ); }); test('claimUploadArtifactsForConversation attaches pre-session uploads to package', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const calls = []; const state = { categories: [{ id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }], spaces: [{ id: 'space-1', user_id: 'user-1', quota_bytes: 1000, used_bytes: 0, reserved_bytes: 0 }], uploads: [ { id: 'upload-1', user_id: 'user-1', space_id: 'space-1', category_id: 'cat-public', filename: 'first.png', status: 'completed', completed_asset_id: 'asset-1', source_session_id: null, source_message_id: 'message-1', }, ], assets: [ { id: 'asset-1', user_id: 'user-1', category_id: 'cat-public', current_version_id: 'version-1', original_filename: 'users/user-1/public/images/first.png', display_name: 'first.png', mime_type: 'image/png', size_bytes: 123, updated_at: 1000, }, ], versions: [ { id: 'version-1', asset_id: 'asset-1', storage_key: 'users/user-1/public/images/first.png', scan_status: 'passed', }, ], }; const service = createAssetService(createMockPool(state), { storageRoot, conversationPackageRegistry: { async ensurePackage(input) { calls.push(['ensurePackage', input]); return { id: 'cp_session-1' }; }, async recordArtifact(input) { calls.push(['recordArtifact', input]); return input; }, async writeManifestForSession(input) { calls.push(['writeManifestForSession', input]); return {}; }, }, }); const result = await service.claimUploadArtifactsForConversation('user-1', { sessionId: 'session-1', messageId: 'message-1', }); assert.equal(result.claimedCount, 1); assert.equal(state.uploads[0].source_session_id, 'session-1'); assert.deepEqual(calls[0], ['ensurePackage', { userId: 'user-1', sessionId: 'session-1', now: calls[0][1].now }]); assert.equal(calls[1][1].artifactKind, 'input_image'); assert.equal(calls[1][1].messageId, 'message-1'); assert.equal(calls[1][1].assetId, 'asset-1'); }); test('completeUpload publishes public images to the public temp image library', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-')); const state = { categories: [ { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, ], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 5 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], uploads: [], assets: [], versions: [], }; let nextId = 0; const service = createAssetService(createMockPool(state), { storageRoot, h5Root, idFactory: () => `id-${++nextId}`, }); const png = await sharp({ create: { width: 24, height: 24, channels: 4, background: { r: 10, g: 160, b: 220, alpha: 0.8 }, }, }) .png() .toBuffer(); const upload = await service.createUpload('user-1', { categoryId: 'cat-public', filename: 'look.png', sizeBytes: png.length, }); await service.writeUploadContent('user-1', upload.id, png); const asset = await service.completeUpload('user-1', upload.id); assert.equal(asset.status, 'ready'); assert.match(asset.publicUrl, /^http:\/\/localhost:20081\/unsafe\//); assert.match(state.versions[0].storage_key, /^users\/user-1\/images\/\d{4}-\d{2}-\d{2}\/look-id2\.png$/); await assert.doesNotReject(() => fs.stat(path.join(storageRoot, state.versions[0].storage_key)), ); await assert.doesNotReject(() => fs.stat(path.join(h5Root, 'MindSpace/user-1', state.assets[0].original_filename)), ); }); test('completeUpload normalizes large images into a bounded master asset', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const state = { categories: [ { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, ], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 60 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], uploads: [], assets: [], versions: [], }; let nextId = 0; const service = createAssetService(createMockPool(state), { storageRoot, idFactory: () => `id-${++nextId}`, }); const original = await sharp({ create: { width: 4200, height: 2800, channels: 3, background: { r: 220, g: 120, b: 60 }, }, }) .jpeg({ quality: 98 }) .toBuffer(); const upload = await service.createUpload('user-1', { categoryId: 'cat-public', filename: 'camera-roll.jpg', sizeBytes: original.length, }); await service.writeUploadContent('user-1', upload.id, original); const asset = await service.completeUpload('user-1', upload.id); const storedPath = path.join(storageRoot, state.versions[0].storage_key); const storedBuffer = await fs.readFile(storedPath); const metadata = await sharp(storedBuffer).metadata(); assert.equal(asset.status, 'ready'); assert.equal(asset.mimeType, 'image/jpeg'); assert.ok(asset.sizeBytes <= original.length); assert.ok((metadata.width ?? 0) <= 2560); assert.ok((metadata.height ?? 0) <= 2560); }); test('completeUpload gives same-name public images unique storage keys', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-')); const state = { categories: [ { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, ], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 20 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], uploads: [], assets: [], versions: [], }; let nextId = 0; const service = createAssetService(createMockPool(state), { storageRoot, h5Root, idFactory: () => `id-${++nextId}`, }); const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: { r: 120, g: 120, b: 240 }, }, }) .jpeg() .toBuffer(); const first = await service.createUpload('user-1', { categoryId: 'cat-public', filename: 'e20.jpg', sizeBytes: image.length, }); await service.writeUploadContent('user-1', first.id, image); await service.completeUpload('user-1', first.id); const second = await service.createUpload('user-1', { categoryId: 'cat-public', filename: 'e20.jpg', sizeBytes: image.length, }); await service.writeUploadContent('user-1', second.id, image); await service.completeUpload('user-1', second.id); assert.equal(state.versions.length, 2); assert.notEqual(state.versions[0].storage_key, state.versions[1].storage_key); assert.match(state.versions[0].storage_key, /\/e20-id2\.jpg$/); assert.match(state.versions[1].storage_key, /\/e20-id5\.jpg$/); }); test('createUpload rejects another users category id', async () => { const state = { categories: [ { id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }, { id: 'cat-2', user_id: 'user-2', space_id: 'space-2', category_code: 'oa' }, ], spaces: [ { id: 'space-1', user_id: 'user-1', quota_bytes: 5 * 1024 * 1024, used_bytes: 0, reserved_bytes: 0, status: 'active', }, ], uploads: [], assets: [], versions: [], }; const service = createAssetService(createMockPool(state), { storageRoot: await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')), }); await assert.rejects( () => service.createUpload('user-1', { categoryId: 'cat-2', filename: 'note.txt', sizeBytes: 4, }), (error) => error.code === 'category_not_found', ); }); test('publicUrlForAsset uses MindSpace public URL for workspace-synced images', () => { const url = assetInternals.publicUrlForAsset({ id: 'asset-1', user_id: 'user-1', category_code: 'public', mime_type: 'image/png', original_filename: 'mapo-tofu-recipe.thumbnail.png', workspace_relative_path: 'public/mapo-tofu-recipe.thumbnail.png', storage_key: 'workspace://user-1/public/mapo-tofu-recipe.thumbnail.png', }); assert.match(url, /\/MindSpace\/user-1\/public\/mapo-tofu-recipe\.thumbnail\.png$/); }); test('publicUrlForAsset does not route workspace image paths through imgproxy', () => { const url = assetInternals.publicUrlForAsset({ id: 'asset-2', user_id: 'user-1', category_code: 'public', mime_type: 'image/jpeg', original_filename: 'images/2026-07-04/photo.jpg', workspace_relative_path: 'public/images/2026-07-04/photo.jpg', storage_key: 'workspace://user-1/public/images/2026-07-04/photo.jpg', }); assert.match(url, /\/MindSpace\/user-1\/public\/images\/2026-07-04\/photo\.jpg$/); assert.doesNotMatch(url, /20081|workspace:\/\//); });