e2ad3bf62b
- 新增 public share widget 与 workspace relative path 解析 - 增强 publications/chat-plaza 发布链路与缩略图 demo - 补充 schema、cover 检查脚本与回归测试 Co-authored-by: Cursor <cursoragent@cursor.com>
719 lines
24 KiB
JavaScript
719 lines
24 KiB
JavaScript
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('<script>alert(1)</script>');
|
|
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:\/\//);
|
|
});
|