Files
memind/mindspace-assets.test.mjs
T
2026-06-29 22:20:04 +08:00

450 lines
14 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 { createAssetService, validateUploadRequest } from './mindspace-assets.mjs';
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.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],
});
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')) {
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],
status: params[13],
risk_level: params[11],
current_version_id: params[8],
original_filename: params[6],
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("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')) {
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("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 5MB 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: 4 * 1024 * 1024,
}),
);
assert.throws(
() =>
validateUploadRequest({
filename: 'cover.jpg',
sizeBytes: 4 * 1024 * 1024 + 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('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',
);
});