@@ -1878,4 +1878,279 @@ describe('Parse.File testing', () => {
18781878 ) . toBeRejectedWith ( jasmine . objectContaining ( { status : 400 } ) ) ;
18791879 } ) ;
18801880 } ) ;
1881+
1882+ describe ( 'streaming binary uploads' , ( ) => {
1883+ afterEach ( ( ) => {
1884+ Parse . Cloud . _removeAllHooks ( ) ;
1885+ } ) ;
1886+
1887+ describe ( 'createSizeLimitedStream' , ( ) => {
1888+ const { createSizeLimitedStream } = require ( '../lib/Routers/FilesRouter' ) ;
1889+ const { Readable } = require ( 'stream' ) ;
1890+
1891+ it ( 'passes data through when under limit' , async ( ) => {
1892+ const input = Readable . from ( Buffer . from ( 'hello' ) ) ;
1893+ const limited = createSizeLimitedStream ( input , 100 ) ;
1894+ const chunks = [ ] ;
1895+ for await ( const chunk of limited ) {
1896+ chunks . push ( chunk ) ;
1897+ }
1898+ expect ( Buffer . concat ( chunks ) . toString ( ) ) . toBe ( 'hello' ) ;
1899+ } ) ;
1900+
1901+ it ( 'destroys stream when data exceeds limit' , async ( ) => {
1902+ const input = Readable . from ( Buffer . from ( 'hello world, this is too long' ) ) ;
1903+ const limited = createSizeLimitedStream ( input , 5 ) ;
1904+ const chunks = [ ] ;
1905+ try {
1906+ for await ( const chunk of limited ) {
1907+ chunks . push ( chunk ) ;
1908+ }
1909+ fail ( 'should have thrown' ) ;
1910+ } catch ( e ) {
1911+ expect ( e . message ) . toContain ( 'exceeds' ) ;
1912+ }
1913+ } ) ;
1914+
1915+ } ) ;
1916+
1917+ it ( 'streams binary upload with X-Parse-Upload-Mode header' , async ( ) => {
1918+ const headers = {
1919+ 'Content-Type' : 'application/octet-stream' ,
1920+ 'X-Parse-Application-Id' : 'test' ,
1921+ 'X-Parse-REST-API-Key' : 'rest' ,
1922+ 'X-Parse-Upload-Mode' : 'stream' ,
1923+ } ;
1924+ let response ;
1925+ try {
1926+ response = await request ( {
1927+ method : 'POST' ,
1928+ headers : headers ,
1929+ url : 'http://localhost:8378/1/files/stream-test.txt' ,
1930+ body : 'streaming file content' ,
1931+ } ) ;
1932+ } catch ( e ) {
1933+ fail ( 'Request failed: status=' + e . status + ' text=' + e . text + ' data=' + JSON . stringify ( e . data ) ) ;
1934+ return ;
1935+ }
1936+ const b = response . data ;
1937+ expect ( b . name ) . toMatch ( / _ s t r e a m - t e s t .t x t $ / ) ;
1938+ expect ( b . url ) . toMatch ( / s t r e a m - t e s t \. t x t $ / ) ;
1939+ const getResponse = await request ( { url : b . url } ) ;
1940+ expect ( getResponse . text ) . toEqual ( 'streaming file content' ) ;
1941+ } ) ;
1942+
1943+ it ( 'infers content type from extension when Content-Type header is missing' , async ( ) => {
1944+ const headers = {
1945+ 'X-Parse-Application-Id' : 'test' ,
1946+ 'X-Parse-REST-API-Key' : 'rest' ,
1947+ 'X-Parse-Upload-Mode' : 'stream' ,
1948+ } ;
1949+ const response = await request ( {
1950+ method : 'POST' ,
1951+ headers : headers ,
1952+ url : 'http://localhost:8378/1/files/inferred.txt' ,
1953+ body : 'inferred content type' ,
1954+ } ) ;
1955+ const b = response . data ;
1956+ expect ( b . name ) . toMatch ( / _ i n f e r r e d .t x t $ / ) ;
1957+ const getResponse = await request ( { url : b . url } ) ;
1958+ expect ( getResponse . text ) . toEqual ( 'inferred content type' ) ;
1959+ } ) ;
1960+
1961+ it ( 'uses buffered path without X-Parse-Upload-Mode header' , async ( ) => {
1962+ const headers = {
1963+ 'Content-Type' : 'application/octet-stream' ,
1964+ 'X-Parse-Application-Id' : 'test' ,
1965+ 'X-Parse-REST-API-Key' : 'rest' ,
1966+ } ;
1967+ const response = await request ( {
1968+ method : 'POST' ,
1969+ headers : headers ,
1970+ url : 'http://localhost:8378/1/files/buffered-test.txt' ,
1971+ body : 'buffered file content' ,
1972+ } ) ;
1973+ const b = response . data ;
1974+ expect ( b . name ) . toMatch ( / _ b u f f e r e d - t e s t .t x t $ / ) ;
1975+ const getResponse = await request ( { url : b . url } ) ;
1976+ expect ( getResponse . text ) . toEqual ( 'buffered file content' ) ;
1977+ } ) ;
1978+
1979+ it ( 'rejects streaming upload exceeding size limit' , async ( ) => {
1980+ await reconfigureServer ( { maxUploadSize : '10b' } ) ;
1981+ const headers = {
1982+ 'Content-Type' : 'application/octet-stream' ,
1983+ 'X-Parse-Application-Id' : 'test' ,
1984+ 'X-Parse-REST-API-Key' : 'rest' ,
1985+ 'X-Parse-Upload-Mode' : 'stream' ,
1986+ } ;
1987+ try {
1988+ await request ( {
1989+ method : 'POST' ,
1990+ headers : headers ,
1991+ url : 'http://localhost:8378/1/files/big-file.txt' ,
1992+ body : 'this content is definitely longer than 10 bytes' ,
1993+ } ) ;
1994+ fail ( 'should have thrown' ) ;
1995+ } catch ( response ) {
1996+ expect ( response . data . code ) . toBe ( Parse . Error . FILE_SAVE_ERROR ) ;
1997+ expect ( response . data . error ) . toContain ( 'exceeds' ) ;
1998+ }
1999+ } ) ;
2000+
2001+ it ( 'rejects streaming upload with Content-Length exceeding limit' , async ( ) => {
2002+ await reconfigureServer ( { maxUploadSize : '10b' } ) ;
2003+ const headers = {
2004+ 'Content-Type' : 'application/octet-stream' ,
2005+ 'X-Parse-Application-Id' : 'test' ,
2006+ 'X-Parse-REST-API-Key' : 'rest' ,
2007+ 'X-Parse-Upload-Mode' : 'stream' ,
2008+ 'Content-Length' : '99999' ,
2009+ } ;
2010+ try {
2011+ await request ( {
2012+ method : 'POST' ,
2013+ headers : headers ,
2014+ url : 'http://localhost:8378/1/files/big-file.txt' ,
2015+ body : 'hi' ,
2016+ } ) ;
2017+ fail ( 'should have thrown' ) ;
2018+ } catch ( response ) {
2019+ expect ( response . data . code ) . toBe ( Parse . Error . FILE_SAVE_ERROR ) ;
2020+ expect ( response . data . error ) . toContain ( 'exceeds' ) ;
2021+ }
2022+ } ) ;
2023+
2024+ it ( 'fires beforeSave trigger with request.stream = true on streaming upload' , async ( ) => {
2025+ let receivedStream ;
2026+ let receivedData ;
2027+ Parse . Cloud . beforeSave ( Parse . File , ( request ) => {
2028+ receivedStream = request . stream ;
2029+ receivedData = request . file . _data ;
2030+ request . file . addMetadata ( 'source' , 'stream' ) ;
2031+ request . file . addTag ( 'env' , 'test' ) ;
2032+ } ) ;
2033+ const headers = {
2034+ 'Content-Type' : 'application/octet-stream' ,
2035+ 'X-Parse-Application-Id' : 'test' ,
2036+ 'X-Parse-REST-API-Key' : 'rest' ,
2037+ 'X-Parse-Upload-Mode' : 'stream' ,
2038+ } ;
2039+ const response = await request ( {
2040+ method : 'POST' ,
2041+ headers : headers ,
2042+ url : 'http://localhost:8378/1/files/trigger-test.txt' ,
2043+ body : 'trigger test content' ,
2044+ } ) ;
2045+ expect ( response . data . name ) . toMatch ( / _ t r i g g e r - t e s t .t x t $ / ) ;
2046+ expect ( receivedStream ) . toBe ( true ) ;
2047+ expect ( receivedData ) . toBeFalsy ( ) ;
2048+ const getResponse = await request ( { url : response . data . url } ) ;
2049+ expect ( getResponse . text ) . toEqual ( 'trigger test content' ) ;
2050+ } ) ;
2051+
2052+ it ( 'rejects streaming upload when beforeSave trigger throws' , async ( ) => {
2053+ Parse . Cloud . beforeSave ( Parse . File , ( ) => {
2054+ throw new Parse . Error ( Parse . Error . SCRIPT_FAILED , 'Upload rejected' ) ;
2055+ } ) ;
2056+ const headers = {
2057+ 'Content-Type' : 'application/octet-stream' ,
2058+ 'X-Parse-Application-Id' : 'test' ,
2059+ 'X-Parse-REST-API-Key' : 'rest' ,
2060+ 'X-Parse-Upload-Mode' : 'stream' ,
2061+ } ;
2062+ try {
2063+ await request ( {
2064+ method : 'POST' ,
2065+ headers : headers ,
2066+ url : 'http://localhost:8378/1/files/rejected.txt' ,
2067+ body : 'rejected content' ,
2068+ } ) ;
2069+ fail ( 'should have thrown' ) ;
2070+ } catch ( response ) {
2071+ expect ( response . data . code ) . toBe ( Parse . Error . SCRIPT_FAILED ) ;
2072+ expect ( response . data . error ) . toBe ( 'Upload rejected' ) ;
2073+ }
2074+ } ) ;
2075+
2076+ it ( 'skips save when beforeSave trigger returns Parse.File with URL on streaming upload' , async ( ) => {
2077+ Parse . Cloud . beforeSave ( Parse . File , ( ) => {
2078+ return Parse . File . fromJSON ( {
2079+ __type : 'File' ,
2080+ name : 'existing.txt' ,
2081+ url : 'http://example.com/existing.txt' ,
2082+ } ) ;
2083+ } ) ;
2084+ const headers = {
2085+ 'Content-Type' : 'application/octet-stream' ,
2086+ 'X-Parse-Application-Id' : 'test' ,
2087+ 'X-Parse-REST-API-Key' : 'rest' ,
2088+ 'X-Parse-Upload-Mode' : 'stream' ,
2089+ } ;
2090+ const response = await request ( {
2091+ method : 'POST' ,
2092+ headers : headers ,
2093+ url : 'http://localhost:8378/1/files/skip-save.txt' ,
2094+ body : 'should not be saved' ,
2095+ } ) ;
2096+ expect ( response . data . url ) . toBe ( 'http://example.com/existing.txt' ) ;
2097+ expect ( response . data . name ) . toBe ( 'existing.txt' ) ;
2098+ } ) ;
2099+
2100+ it ( 'fires afterSave trigger with request.stream = true on streaming upload' , async ( ) => {
2101+ let afterSaveStream ;
2102+ let afterSaveData ;
2103+ let afterSaveUrl ;
2104+ Parse . Cloud . afterSave ( Parse . File , ( request ) => {
2105+ afterSaveStream = request . stream ;
2106+ afterSaveData = request . file . _data ;
2107+ afterSaveUrl = request . file . _url ;
2108+ } ) ;
2109+ const headers = {
2110+ 'Content-Type' : 'application/octet-stream' ,
2111+ 'X-Parse-Application-Id' : 'test' ,
2112+ 'X-Parse-REST-API-Key' : 'rest' ,
2113+ 'X-Parse-Upload-Mode' : 'stream' ,
2114+ } ;
2115+ const response = await request ( {
2116+ method : 'POST' ,
2117+ headers : headers ,
2118+ url : 'http://localhost:8378/1/files/after-save.txt' ,
2119+ body : 'after save content' ,
2120+ } ) ;
2121+ expect ( response . data . name ) . toMatch ( / _ a f t e r - s a v e .t x t $ / ) ;
2122+ expect ( afterSaveStream ) . toBe ( true ) ;
2123+ expect ( afterSaveData ) . toBeFalsy ( ) ;
2124+ expect ( afterSaveUrl ) . toBeTruthy ( ) ;
2125+ } ) ;
2126+
2127+ it ( 'verifies FilesAdapter default supportsStreaming is false' , ( ) => {
2128+ const { FilesAdapter } = require ( '../lib/Adapters/Files/FilesAdapter' ) ;
2129+ const adapter = new FilesAdapter ( ) ;
2130+ expect ( adapter . supportsStreaming ) . toBe ( false ) ;
2131+ } ) ;
2132+
2133+ it ( 'legacy JSON-wrapped upload still works' , async ( ) => {
2134+ await reconfigureServer ( {
2135+ fileUpload : {
2136+ enableForPublic : true ,
2137+ fileExtensions : [ '*' ] ,
2138+ } ,
2139+ } ) ;
2140+ const response = await request ( {
2141+ method : 'POST' ,
2142+ url : 'http://localhost:8378/1/files/legacy.txt' ,
2143+ body : JSON . stringify ( {
2144+ _ApplicationId : 'test' ,
2145+ _JavaScriptKey : 'test' ,
2146+ _ContentType : 'text/plain' ,
2147+ base64 : Buffer . from ( 'legacy content' ) . toString ( 'base64' ) ,
2148+ } ) ,
2149+ } ) ;
2150+ const b = response . data ;
2151+ expect ( b . name ) . toMatch ( / _ l e g a c y .t x t $ / ) ;
2152+ const getResponse = await request ( { url : b . url } ) ;
2153+ expect ( getResponse . text ) . toEqual ( 'legacy content' ) ;
2154+ } ) ;
2155+ } ) ;
18812156} ) ;
0 commit comments