'use strict'; const fs = require('fs'); const path = require('path'); const http = require('http'); const { createStrapiInstance } = require('api-tests/strapi'); const { createAuthRequest } = require('api-tests/request'); const { withMockedFetch, createMockResponse } = require('api-tests/mock-fetch'); let strapi; let rq; /** * Parse SSE events from a response buffer * @param {string} data - Raw SSE response data * @returns {Array<{event: string, data: object}>} - Parsed events */ const parseSSEEvents = (data) => { const events = []; const lines = data.split('\n'); let currentEvent = null; let currentData = null; for (const line of lines) { if (line.startsWith('event: ')) { currentEvent = line.slice(7).trim(); } else if (line.startsWith('data: ')) { currentData = line.slice(6).trim(); if (currentEvent && currentData) { try { events.push({ event: currentEvent, data: JSON.parse(currentData) }); } catch { events.push({ event: currentEvent, data: currentData }); } currentEvent = null; currentData = null; } } } return events; }; /** * Make a raw HTTP request that can handle SSE streaming responses */ const makeRawRequest = (strapi, options) => { return new Promise((resolve, reject) => { const { method, path: urlPath, headers, body, formData } = options; const serverAddress = strapi.server.httpServer.address(); const port = typeof serverAddress === 'object' ? serverAddress.port : serverAddress; const reqOptions = { hostname: '127.0.0.1', port, path: urlPath, method, headers: { ...headers, }, }; if (body && !formData) { reqOptions.headers['Content-Type'] = 'application/json'; } const req = http.request(reqOptions, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk.toString(); }); res.on('end', () => { resolve({ statusCode: res.statusCode, headers: res.headers, body: data, events: parseSSEEvents(data), }); }); }); req.on('error', reject); if (body) { req.write(JSON.stringify(body)); } req.end(); }); }; /** * Create a multipart form data request for file upload with SSE streaming */ const makeMultipartSSERequest = (strapi, options) => { return new Promise((resolve, reject) => { const { path: urlPath, headers, files, fields = {} } = options; const serverAddress = strapi.server.httpServer.address(); const port = typeof serverAddress === 'object' ? serverAddress.port : serverAddress; const boundary = `----FormBoundary${Date.now()}`; let body = ''; // Add fields for (const [key, value] of Object.entries(fields)) { body += `--${boundary}\r\n`; body += `Content-Disposition: form-data; name="${key}"\r\n\r\n`; body += `${typeof value === 'object' ? JSON.stringify(value) : value}\r\n`; } // Add files const fileBuffers = []; for (const file of files) { const fileContent = fs.readFileSync(file.path); const filename = path.basename(file.path); body += `--${boundary}\r\n`; body += `Content-Disposition: form-data; name="files"; filename="${filename}"\r\n`; body += `Content-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`; fileBuffers.push({ prefix: body, content: fileContent, }); body = '\r\n'; } body += `--${boundary}--\r\n`; // Build the complete body buffer const bodyParts = []; for (let i = 0; i < fileBuffers.length; i++) { bodyParts.push(Buffer.from(fileBuffers[i].prefix, 'utf8')); bodyParts.push(fileBuffers[i].content); } bodyParts.push(Buffer.from(body, 'utf8')); const fullBody = Buffer.concat(bodyParts); const reqOptions = { hostname: '127.0.0.1', port, path: urlPath, method: 'POST', headers: { ...headers, 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': fullBody.length, }, }; const req = http.request(reqOptions, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk.toString(); }); res.on('end', () => { resolve({ statusCode: res.statusCode, headers: res.headers, body: data, events: parseSSEEvents(data), }); }); }); req.on('error', reject); req.write(fullBody); req.end(); }); }; describe('Upload SSE Streaming', () => { beforeAll(async () => { strapi = await createStrapiInstance(); rq = await createAuthRequest({ strapi }); }); afterAll(async () => { await strapi.destroy(); }); describe('POST /upload/unstable/stream - File streaming upload', () => { describe('Authentication', () => { test('Rejects unauthenticated requests', async () => { const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream', headers: {}, formData: true, }); expect(res.statusCode).toBe(401); }); }); describe('Validation', () => { test('Rejects when no files are provided', async () => { // Since we're using createAuthRequest, we need to get the token differently // Let's use the regular endpoint first to verify behavior const res = await rq({ method: 'POST', url: '/upload/unstable/stream', formData: {}, }); expect(res.statusCode).toBe(400); }); }); describe('SSE Events', () => { let authToken; beforeAll(async () => { // Get auth token for raw requests const loginRes = await rq({ method: 'POST', url: '/admin/login', body: { email: 'admin@strapi.io', password: 'Password123', }, }); authToken = loginRes.body?.data?.token; }); test('Streams file:uploading and file:complete events for successful upload', async () => { if (!authToken) { // Skip if we couldn't get auth token (super admin setup may differ) return; } const res = await makeMultipartSSERequest(strapi, { path: '/upload/unstable/stream', headers: { Authorization: `Bearer ${authToken}`, }, files: [{ path: path.join(__dirname, '../utils/rec.jpg'), type: 'image/jpeg' }], }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toBe('text/event-stream'); // Check for expected events const eventTypes = res.events.map((e) => e.event); expect(eventTypes).toContain('file:uploading'); expect(eventTypes).toContain('stream:complete'); // Verify file:uploading event structure const uploadingEvent = res.events.find((e) => e.event === 'file:uploading'); expect(uploadingEvent).toBeDefined(); expect(uploadingEvent.data).toMatchObject({ index: 0, total: 1, }); // Verify stream:complete event structure const completeEvent = res.events.find((e) => e.event === 'stream:complete'); expect(completeEvent).toBeDefined(); expect(completeEvent.data).toHaveProperty('data'); expect(completeEvent.data).toHaveProperty('errors'); }); test('Streams events for multiple files', async () => { if (!authToken) { return; } const res = await makeMultipartSSERequest(strapi, { path: '/upload/unstable/stream', headers: { Authorization: `Bearer ${authToken}`, }, files: [ { path: path.join(__dirname, '../utils/rec.jpg'), type: 'image/jpeg' }, { path: path.join(__dirname, '../utils/rec.jpg'), type: 'image/jpeg' }, ], }); expect(res.statusCode).toBe(200); // Should have uploading events for each file const uploadingEvents = res.events.filter((e) => e.event === 'file:uploading'); expect(uploadingEvents.length).toBe(2); // Check totals are correct expect(uploadingEvents[0].data.total).toBe(2); expect(uploadingEvents[1].data.total).toBe(2); // Check indices expect(uploadingEvents[0].data.index).toBe(0); expect(uploadingEvents[1].data.index).toBe(1); }); test('Includes fileInfo in upload', async () => { if (!authToken) { return; } const fileInfo = { name: 'custom-name', caption: 'Test caption', alternativeText: 'Test alt text', }; const res = await makeMultipartSSERequest(strapi, { path: '/upload/unstable/stream', headers: { Authorization: `Bearer ${authToken}`, }, files: [{ path: path.join(__dirname, '../utils/rec.jpg'), type: 'image/jpeg' }], fields: { fileInfo: JSON.stringify(fileInfo), }, }); expect(res.statusCode).toBe(200); const completeEvent = res.events.find((e) => e.event === 'stream:complete'); expect(completeEvent).toBeDefined(); // If upload was successful, check the file has the custom metadata if (completeEvent.data.data && completeEvent.data.data.length > 0) { const uploadedFile = completeEvent.data.data[0]; expect(uploadedFile.caption).toBe('Test caption'); expect(uploadedFile.alternativeText).toBe('Test alt text'); } }); }); describe('File restriction', () => { let authToken; beforeAll(async () => { const loginRes = await rq({ method: 'POST', url: '/admin/login', body: { email: 'admin@strapi.io', password: 'Password123', }, }); authToken = loginRes.body?.data?.token; }); afterEach(() => { // Reset config after each test strapi.config.set('plugin::upload.security', {}); }); test('Reports file:error event when MIME type is denied', async () => { if (!authToken) { return; } strapi.config.set('plugin::upload.security', { deniedTypes: ['image/*'] }); const res = await makeMultipartSSERequest(strapi, { path: '/upload/unstable/stream', headers: { Authorization: `Bearer ${authToken}`, }, files: [{ path: path.join(__dirname, '../utils/rec.jpg'), type: 'image/jpeg' }], }); expect(res.statusCode).toBe(200); // Should have file:error event const errorEvent = res.events.find((e) => e.event === 'file:error'); expect(errorEvent).toBeDefined(); expect(errorEvent.data).toHaveProperty('message'); // stream:complete should report errors const completeEvent = res.events.find((e) => e.event === 'stream:complete'); expect(completeEvent).toBeDefined(); expect(completeEvent.data.errors.length).toBeGreaterThan(0); }); }); }); describe('POST /upload/unstable/stream-from-urls - URL import', () => { describe('Authentication', () => { test('Rejects unauthenticated requests', async () => { const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream-from-urls', headers: {}, body: { urls: ['https://example.com/image.jpg'] }, }); expect(res.statusCode).toBe(401); }); }); describe('Validation', () => { test('Rejects when no URLs are provided', async () => { const res = await rq({ method: 'POST', url: '/upload/unstable/stream-from-urls', body: {}, }); expect(res.statusCode).toBe(400); }); test('Rejects when URLs is empty array', async () => { const res = await rq({ method: 'POST', url: '/upload/unstable/stream-from-urls', body: { urls: [] }, }); expect(res.statusCode).toBe(400); }); test('Rejects when URLs is not an array', async () => { const res = await rq({ method: 'POST', url: '/upload/unstable/stream-from-urls', body: { urls: 'https://example.com/image.jpg' }, }); expect(res.statusCode).toBe(400); }); test('Rejects when more than 20 URLs are provided', async () => { const urls = Array.from({ length: 21 }, (_, i) => `https://example.com/image${i}.jpg`); const res = await rq({ method: 'POST', url: '/upload/unstable/stream-from-urls', body: { urls }, }); expect(res.statusCode).toBe(400); }); }); describe('SSE Events', () => { let authToken; beforeAll(async () => { const loginRes = await rq({ method: 'POST', url: '/admin/login', body: { email: 'admin@strapi.io', password: 'Password123', }, }); authToken = loginRes.body?.data?.token; }); test('Streams file:fetching event when starting URL fetch', async () => { if (!authToken) { return; } const url = 'https://example.com/nonexistent-image.jpg'; await withMockedFetch( (u) => (u === url ? createMockResponse({ status: 404, body: '' }) : undefined), async () => { const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream-from-urls', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: { urls: [url] }, }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toBe('text/event-stream'); const fetchingEvent = res.events.find((e) => e.event === 'file:fetching'); expect(fetchingEvent).toBeDefined(); expect(fetchingEvent.data).toMatchObject({ url, index: 0, total: 1, }); const completeEvent = res.events.find((e) => e.event === 'stream:complete'); expect(completeEvent).toBeDefined(); } ); }); test('Streams file:error event for invalid URL protocol', async () => { if (!authToken) { return; } const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream-from-urls', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: { urls: ['ftp://example.com/file.jpg'] }, }); expect(res.statusCode).toBe(200); // Should have file:error event for invalid protocol const errorEvent = res.events.find((e) => e.event === 'file:error'); expect(errorEvent).toBeDefined(); expect(errorEvent.data.message).toMatch(/Invalid URL|protocol/i); }); test('Handles multiple URLs with file:fetching events for each', async () => { if (!authToken) { return; } const urls = [ 'https://example.com/image1.jpg', 'https://example.com/image2.jpg', 'https://example.com/image3.jpg', ]; await withMockedFetch( (u) => (urls.includes(u) ? createMockResponse({ status: 404, body: '' }) : undefined), async () => { const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream-from-urls', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: { urls }, }); expect(res.statusCode).toBe(200); const fetchingEvents = res.events.filter((e) => e.event === 'file:fetching'); expect(fetchingEvents.length).toBe(3); for (let i = 0; i < 3; i++) { expect(fetchingEvents[i].data.index).toBe(i); expect(fetchingEvents[i].data.total).toBe(3); expect(fetchingEvents[i].data.url).toBe(urls[i]); } } ); }); }); describe('Error handling', () => { let authToken; beforeAll(async () => { const loginRes = await rq({ method: 'POST', url: '/admin/login', body: { email: 'admin@strapi.io', password: 'Password123', }, }); authToken = loginRes.body?.data?.token; }); test('Reports errors in stream:complete for failed URLs', async () => { if (!authToken) { return; } const url = 'https://example.com/definitely-does-not-exist-12345.jpg'; await withMockedFetch( (u) => (u === url ? createMockResponse({ status: 404, body: '' }) : undefined), async () => { const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream-from-urls', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: { urls: [url] }, }); expect(res.statusCode).toBe(200); const completeEvent = res.events.find((e) => e.event === 'stream:complete'); expect(completeEvent).toBeDefined(); expect(completeEvent.data.errors.length).toBeGreaterThan(0); } ); }); test('Continues processing remaining URLs after one fails', async () => { if (!authToken) { return; } const urls = [ 'ftp://invalid-protocol.com/file.jpg', // Will fail - invalid protocol 'https://example.com/another-image.jpg', // Will be attempted ]; await withMockedFetch( (u) => (u === urls[1] ? createMockResponse({ status: 404, body: '' }) : undefined), async () => { const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream-from-urls', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: { urls }, }); expect(res.statusCode).toBe(200); const fetchingEvents = res.events.filter((e) => e.event === 'file:fetching'); expect(fetchingEvents.length).toBe(2); } ); }); }); describe('Size limit', () => { let authToken; beforeAll(async () => { const loginRes = await rq({ method: 'POST', url: '/admin/login', body: { email: 'admin@strapi.io', password: 'Password123', }, }); authToken = loginRes.body?.data?.token; }); afterEach(() => { // Reset config after each test strapi.config.set('plugin::upload.sizeLimit', 1000000000); // Reset to default 1GB }); test('Rejects files exceeding sizeLimit based on Content-Length header', async () => { if (!authToken) { return; } // Set a very small size limit (100 bytes) strapi.config.set('plugin::upload.sizeLimit', 100); // Use a URL that resolves to a public IP (example.com) so SSRF check passes. // Mock fetch so we don't rely on remote servers; mock returns 200 + Content-Length: 1000 // so the upload service rejects based on size limit. const sizeLimitTestUrl = 'https://example.com/bytes/1000'; await withMockedFetch( (url) => { if (url === sizeLimitTestUrl) { return createMockResponse({ status: 200, headers: { 'Content-Length': '1000' }, body: Buffer.alloc(1000), }); } return undefined; }, async () => { const res = await makeRawRequest(strapi, { method: 'POST', path: '/upload/unstable/stream-from-urls', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: { urls: [sizeLimitTestUrl] }, // 1000 bytes > 100 byte limit }); expect(res.statusCode).toBe(200); const errorEvent = res.events.find((e) => e.event === 'file:error'); expect(errorEvent).toBeDefined(); expect(errorEvent.data.message).toMatch(/too large|size/i); } ); }); }); }); });