From 80117d7c6b0520a0dec4aab3ab60f1327555839a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 24 Mar 2026 19:22:33 +0100 Subject: [PATCH 1/3] feat(uploads): add createFromBuffer for programmatic GridFS storage (#3289) --- lib/services/gridfs.js | 31 +++++ modules/uploads/config/config.uploads.js | 7 + modules/uploads/services/uploads.service.js | 34 +++++ .../uploads.createFromBuffer.unit.tests.js | 131 ++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 modules/uploads/tests/uploads.createFromBuffer.unit.tests.js diff --git a/lib/services/gridfs.js b/lib/services/gridfs.js index 73e6ef82f..dea31074e 100644 --- a/lib/services/gridfs.js +++ b/lib/services/gridfs.js @@ -1,5 +1,6 @@ import path from 'path'; import crypto from 'crypto'; +import { Readable } from 'stream'; import mongoose from 'mongoose'; let storage; @@ -61,6 +62,36 @@ const getStorage = () => { return storage; }; +/** + * Store a buffer in GridFS programmatically (no HTTP upload). + * @param {Buffer} buffer - File content + * @param {string} filename - Unique filename (e.g., 'snapshot-abc123.jpeg') + * @param {string} contentType - MIME type (e.g., 'image/jpeg') + * @param {Object} metadata - GridFS metadata (kind, user, organizationId, etc.) + * @returns {Promise} The stored file document + */ +const createFromBuffer = (buffer, filename, contentType, metadata = {}) => new Promise((resolve, reject) => { + const bucket = new mongoose.mongo.GridFSBucket(mongoose.connection.db, { bucketName: 'uploads' }); + const id = new mongoose.Types.ObjectId(); + const uploadStream = bucket.openUploadStreamWithId(id, filename, { contentType, metadata }); + + const readable = Readable.from(buffer); + readable.pipe( + uploadStream + .on('error', reject) + .on('finish', async () => { + try { + const Uploads = mongoose.model('Uploads'); + const file = await Uploads.findOne({ _id: id }).exec(); + resolve(file); + } catch (err) { + reject(err); + } + }), + ); +}); + export default { getStorage, + createFromBuffer, }; diff --git a/modules/uploads/config/config.uploads.js b/modules/uploads/config/config.uploads.js index 5abb849c6..9c19a0baf 100644 --- a/modules/uploads/config/config.uploads.js +++ b/modules/uploads/config/config.uploads.js @@ -15,6 +15,13 @@ const config = { operations: ['blur', 'bw', 'blur&bw'], }, }, + snapshot: { + kind: 'snapshot', + formats: ['image/jpeg', 'image/png'], + limits: { + fileSize: 5 * 1024 * 1024, // Max file size in bytes (5 MB) + }, + }, }, }; diff --git a/modules/uploads/services/uploads.service.js b/modules/uploads/services/uploads.service.js index f23b94767..312bd28ae 100644 --- a/modules/uploads/services/uploads.service.js +++ b/modules/uploads/services/uploads.service.js @@ -1,7 +1,12 @@ /** * Module dependencies */ +import crypto from 'crypto'; + +import config from '../../../config/index.js'; +import AppError from '../../../lib/helpers/AppError.js'; import multerService from '../../../lib/services/multer.js'; +import gridfs from '../../../lib/services/gridfs.js'; import UploadRepository from '../repositories/uploads.repository.js'; /** @@ -53,9 +58,38 @@ const remove = async (upload) => { return Promise.resolve(result); }; +/** + * @desc Store a buffer as an upload programmatically (no HTTP request) + * @param {Buffer} buffer - File content + * @param {string} contentType - MIME type (e.g., 'image/jpeg') + * @param {string} kind - Upload kind matching config (e.g., 'snapshot') + * @param {Object} metadata - Additional metadata (user, organizationId, etc.) + * @returns {Promise} The created upload document + */ +const createFromBuffer = async (buffer, contentType, kind, metadata = {}) => { + const kindConfig = config.uploads?.[kind]; + if (!kindConfig) throw new AppError(`Upload: unknown kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 }); + + if (!kindConfig.formats.includes(contentType)) { + throw new AppError(`Upload: content type "${contentType}" not allowed for kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 }); + } + + if (kindConfig.limits?.fileSize && buffer.length > kindConfig.limits.fileSize) { + throw new AppError(`Upload: buffer size ${buffer.length} exceeds limit ${kindConfig.limits.fileSize}`, { code: 'SERVICE_ERROR', status: 422 }); + } + + const MIME_TO_EXT = { 'image/jpeg': 'jpeg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'application/pdf': 'pdf' }; + const ext = MIME_TO_EXT[contentType] || 'bin'; + const filename = `${crypto.randomBytes(32).toString('hex')}.${ext}`; + + const result = await gridfs.createFromBuffer(buffer, filename, contentType, { ...metadata, kind, contentType }); + return result; +}; + export default { get, getStream, update, remove, + createFromBuffer, }; diff --git a/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js new file mode 100644 index 000000000..bb94cfac4 --- /dev/null +++ b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js @@ -0,0 +1,131 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach } from '@jest/globals'; + +/** + * Unit tests for uploads createFromBuffer service + */ +describe('Uploads createFromBuffer unit tests:', () => { + let UploadsService; + let mockGridfs; + let mockConfig; + + const fakeFile = { + _id: '507f1f77bcf86cd799439011', + filename: 'abc123.jpeg', + contentType: 'image/jpeg', + metadata: { kind: 'snapshot', contentType: 'image/jpeg' }, + length: 1024, + }; + + beforeEach(async () => { + jest.resetModules(); + + mockGridfs = { + createFromBuffer: jest.fn().mockResolvedValue(fakeFile), + getStorage: jest.fn(), + }; + + mockConfig = { + uploads: { + snapshot: { + kind: 'snapshot', + formats: ['image/jpeg', 'image/png'], + limits: { fileSize: 5 * 1024 * 1024 }, + }, + avatar: { + kind: 'avatar', + formats: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'], + limits: { fileSize: 1 * 1024 * 1024 }, + }, + }, + }; + + jest.unstable_mockModule('../../../lib/services/gridfs.js', () => ({ + default: mockGridfs, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: mockConfig, + })); + + jest.unstable_mockModule('../../../lib/services/multer.js', () => ({ + default: { generateFileName: jest.fn() }, + })); + + jest.unstable_mockModule('../repositories/uploads.repository.js', () => ({ + default: { + get: jest.fn(), + getStream: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }, + })); + + UploadsService = (await import('../services/uploads.service.js')).default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should store a valid JPEG buffer and return upload document', async () => { + const buffer = Buffer.alloc(1024); + const result = await UploadsService.createFromBuffer(buffer, 'image/jpeg', 'snapshot', { user: '507f1f77bcf86cd799439011' }); + + expect(result).toBeDefined(); + expect(result.contentType).toBe('image/jpeg'); + expect(result.metadata.kind).toBe('snapshot'); + expect(mockGridfs.createFromBuffer).toHaveBeenCalledTimes(1); + + const [buf, filename, contentType, metadata] = mockGridfs.createFromBuffer.mock.calls[0]; + expect(buf).toBe(buffer); + expect(filename).toMatch(/^[a-f0-9]{64}\.jpeg$/); + expect(contentType).toBe('image/jpeg'); + expect(metadata.kind).toBe('snapshot'); + expect(metadata.user).toBe('507f1f77bcf86cd799439011'); + }); + + test('should store a valid PNG buffer and return upload document', async () => { + const buffer = Buffer.alloc(512); + mockGridfs.createFromBuffer.mockResolvedValue({ ...fakeFile, contentType: 'image/png' }); + + const result = await UploadsService.createFromBuffer(buffer, 'image/png', 'snapshot'); + expect(result).toBeDefined(); + expect(result.contentType).toBe('image/png'); + + const [, filename] = mockGridfs.createFromBuffer.mock.calls[0]; + expect(filename).toMatch(/^[a-f0-9]{64}\.png$/); + }); + + test('should throw error when buffer exceeds size limit', async () => { + const oversizedBuffer = Buffer.alloc(6 * 1024 * 1024); // 6 MB > 5 MB limit + + await expect( + UploadsService.createFromBuffer(oversizedBuffer, 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer size .* exceeds limit/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when content type is not allowed for kind', async () => { + const buffer = Buffer.alloc(1024); + + await expect( + UploadsService.createFromBuffer(buffer, 'application/pdf', 'snapshot'), + ).rejects.toThrow(/content type .* not allowed/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when kind is unknown', async () => { + const buffer = Buffer.alloc(1024); + + await expect( + UploadsService.createFromBuffer(buffer, 'image/jpeg', 'unknown'), + ).rejects.toThrow(/unknown kind/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); +}); From d4c3dc3804d3168dae933a5bda00cb04c7bdb0c5 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 24 Mar 2026 19:32:09 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(uploads):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20input=20validation,=20stream=20safety,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Readable.from(buffer) → Readable.from([buffer]) to avoid byte-by-byte iteration - Add readable error handler for reliable Promise rejection on stream failures - Add Buffer.isBuffer validation with clear AppError (422) for invalid inputs - Add defensive Array.isArray check for kindConfig.formats - Extract MIME_TO_EXT map to module-level constant - Add tests for null/undefined buffer, non-Buffer input, and missing formats --- lib/services/gridfs.js | 3 +- modules/uploads/services/uploads.service.js | 17 ++++++++++- .../uploads.createFromBuffer.unit.tests.js | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/services/gridfs.js b/lib/services/gridfs.js index dea31074e..d8f2847f9 100644 --- a/lib/services/gridfs.js +++ b/lib/services/gridfs.js @@ -75,7 +75,8 @@ const createFromBuffer = (buffer, filename, contentType, metadata = {}) => new P const id = new mongoose.Types.ObjectId(); const uploadStream = bucket.openUploadStreamWithId(id, filename, { contentType, metadata }); - const readable = Readable.from(buffer); + const readable = Readable.from([buffer]); + readable.on('error', reject); readable.pipe( uploadStream .on('error', reject) diff --git a/modules/uploads/services/uploads.service.js b/modules/uploads/services/uploads.service.js index 312bd28ae..0c8e51cbe 100644 --- a/modules/uploads/services/uploads.service.js +++ b/modules/uploads/services/uploads.service.js @@ -9,6 +9,14 @@ import multerService from '../../../lib/services/multer.js'; import gridfs from '../../../lib/services/gridfs.js'; import UploadRepository from '../repositories/uploads.repository.js'; +const MIME_TO_EXT = { + 'image/jpeg': 'jpeg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'application/pdf': 'pdf', +}; + /** * @desc Function to ask repository to get an upload * @param {String} uploadName @@ -67,9 +75,17 @@ const remove = async (upload) => { * @returns {Promise} The created upload document */ const createFromBuffer = async (buffer, contentType, kind, metadata = {}) => { + if (!Buffer.isBuffer(buffer)) { + throw new AppError('Upload: buffer is required and must be a Buffer', { code: 'SERVICE_ERROR', status: 422 }); + } + const kindConfig = config.uploads?.[kind]; if (!kindConfig) throw new AppError(`Upload: unknown kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 }); + if (!Array.isArray(kindConfig.formats)) { + throw new AppError(`Upload: kind "${kind}" has no formats configured`, { code: 'SERVICE_ERROR', status: 500 }); + } + if (!kindConfig.formats.includes(contentType)) { throw new AppError(`Upload: content type "${contentType}" not allowed for kind "${kind}"`, { code: 'SERVICE_ERROR', status: 422 }); } @@ -78,7 +94,6 @@ const createFromBuffer = async (buffer, contentType, kind, metadata = {}) => { throw new AppError(`Upload: buffer size ${buffer.length} exceeds limit ${kindConfig.limits.fileSize}`, { code: 'SERVICE_ERROR', status: 422 }); } - const MIME_TO_EXT = { 'image/jpeg': 'jpeg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'application/pdf': 'pdf' }; const ext = MIME_TO_EXT[contentType] || 'bin'; const filename = `${crypto.randomBytes(32).toString('hex')}.${ext}`; diff --git a/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js index bb94cfac4..a89f6300a 100644 --- a/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js +++ b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js @@ -128,4 +128,34 @@ describe('Uploads createFromBuffer unit tests:', () => { expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); }); + + test('should throw error when buffer is null or undefined', async () => { + await expect( + UploadsService.createFromBuffer(null, 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer is required/); + + await expect( + UploadsService.createFromBuffer(undefined, 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer is required/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when buffer is not a Buffer', async () => { + await expect( + UploadsService.createFromBuffer('not a buffer', 'image/jpeg', 'snapshot'), + ).rejects.toThrow(/buffer is required/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); + + test('should throw error when kind has no formats configured', async () => { + mockConfig.uploads.broken = { kind: 'broken', limits: { fileSize: 1024 } }; + + await expect( + UploadsService.createFromBuffer(Buffer.alloc(10), 'image/jpeg', 'broken'), + ).rejects.toThrow(/no formats configured/); + + expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); + }); }); From cbccbb658b0376c9f7877c4959f28412f32a2715 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 24 Mar 2026 19:41:52 +0100 Subject: [PATCH 3/3] test(uploads): add empty buffer test and clarify runtime config pattern --- .../tests/uploads.createFromBuffer.unit.tests.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js index a89f6300a..5dd32cf2f 100644 --- a/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js +++ b/modules/uploads/tests/uploads.createFromBuffer.unit.tests.js @@ -149,7 +149,18 @@ describe('Uploads createFromBuffer unit tests:', () => { expect(mockGridfs.createFromBuffer).not.toHaveBeenCalled(); }); + test('should accept empty buffer (0-byte file)', async () => { + const emptyBuffer = Buffer.alloc(0); + mockGridfs.createFromBuffer.mockResolvedValue({ ...fakeFile, length: 0 }); + + const result = await UploadsService.createFromBuffer(emptyBuffer, 'image/jpeg', 'snapshot'); + expect(result).toBeDefined(); + expect(result.length).toBe(0); + expect(mockGridfs.createFromBuffer).toHaveBeenCalledTimes(1); + }); + test('should throw error when kind has no formats configured', async () => { + // Adding 'broken' kind at runtime — service reads config dynamically via module reference mockConfig.uploads.broken = { kind: 'broken', limits: { fileSize: 1024 } }; await expect(