@@ -670,4 +670,292 @@ describe('API Routes', () => {
670670 } ) ;
671671 } ) ;
672672 } ) ;
673+
674+ describe ( 'GET /v1/static/*' , ( ) => {
675+ beforeEach ( ( ) => {
676+ // Reset all mocks before each test
677+ mockFileExists . mockReset ( ) ;
678+ mockGetMetadata . mockReset ( ) ;
679+ mockCreateReadStream . mockReset ( ) ;
680+ mockFile . mockClear ( ) ;
681+ mockBucket . mockClear ( ) ;
682+ } ) ;
683+
684+ describe ( 'Valid file requests' , ( ) => {
685+ it ( 'should return file content for valid path' , async ( ) => {
686+ const fileContent = JSON . stringify ( { data : 'test' } ) ;
687+ const readable = Readable . from ( [ fileContent ] ) ;
688+
689+ mockFileExists . mockResolvedValue ( [ true ] ) ;
690+ mockGetMetadata . mockResolvedValue ( [ {
691+ contentType : 'application/json' ,
692+ etag : '"abc123"' ,
693+ size : fileContent . length
694+ } ] ) ;
695+ mockCreateReadStream . mockReturnValue ( readable ) ;
696+
697+ const res = await request ( app )
698+ . get ( '/v1/static/reports/2024/data.json' )
699+ . expect ( 200 ) ;
700+
701+ expect ( res . headers [ 'content-type' ] ) . toContain ( 'application/json' ) ;
702+ expect ( res . headers [ 'cache-control' ] ) . toContain ( 'public' ) ;
703+ expect ( res . headers [ 'access-control-allow-origin' ] ) . toEqual ( '*' ) ;
704+ } ) ;
705+
706+ it ( 'should infer MIME type from file extension when not in metadata' , async ( ) => {
707+ const fileContent = '{"test": true}' ;
708+ const readable = Readable . from ( [ fileContent ] ) ;
709+
710+ mockFileExists . mockResolvedValue ( [ true ] ) ;
711+ mockGetMetadata . mockResolvedValue ( [ {
712+ etag : '"abc123"' ,
713+ size : fileContent . length
714+ } ] ) ;
715+ mockCreateReadStream . mockReturnValue ( readable ) ;
716+
717+ const res = await request ( app )
718+ . get ( '/v1/static/reports/data.json' )
719+ . expect ( 200 ) ;
720+
721+ expect ( res . headers [ 'content-type' ] ) . toContain ( 'application/json' ) ;
722+ } ) ;
723+
724+ it ( 'should handle CORS preflight requests' , async ( ) => {
725+ const res = await request ( app )
726+ . options ( '/v1/static/reports/data.json' )
727+ . set ( 'Origin' , 'http://example.com' )
728+ . set ( 'Access-Control-Request-Method' , 'GET' )
729+ . set ( 'Access-Control-Request-Headers' , 'Content-Type' ) ;
730+
731+ expect ( res . statusCode ) . toEqual ( 204 ) ;
732+ expect ( res . headers [ 'access-control-allow-origin' ] ) . toEqual ( '*' ) ;
733+ } ) ;
734+ } ) ;
735+
736+ describe ( 'Invalid file paths (directory traversal attempts)' , ( ) => {
737+ it ( 'should reject paths containing double dot sequences' , async ( ) => {
738+ // Test with '..' embedded in the path that won't be normalized away
739+ const res = await request ( app )
740+ . get ( '/v1/static/reports/..hidden/passwd' )
741+
742+ expect ( res . body ) . toHaveProperty ( 'error' , 'Invalid file path' ) ;
743+ } ) ;
744+
745+ it ( 'should reject paths with double slashes' , async ( ) => {
746+ const res = await request ( app )
747+ . get ( '/v1/static/reports//data.json' )
748+ . expect ( 400 ) ;
749+
750+ expect ( res . body ) . toHaveProperty ( 'error' , 'Invalid file path' ) ;
751+ } ) ;
752+
753+ it ( 'should reject paths with encoded double dots' , async ( ) => {
754+ // URL-encoded '..' = %2e%2e
755+ mockFileExists . mockResolvedValue ( [ false ] ) ; // Will be checked after validation
756+
757+ const res = await request ( app )
758+ . get ( '/v1/static/reports/%2e%2e/secret/passwd' ) ;
759+
760+ // Should either be rejected as invalid or not found
761+ expect ( [ 400 , 404 ] ) . toContain ( res . statusCode ) ;
762+ } ) ;
763+ } ) ;
764+
765+ describe ( 'Non-existent files (404 handling)' , ( ) => {
766+ it ( 'should return 404 for non-existent files' , async ( ) => {
767+ mockFileExists . mockResolvedValue ( [ false ] ) ;
768+
769+ const res = await request ( app )
770+ . get ( '/v1/static/reports/nonexistent.json' )
771+ . expect ( 404 ) ;
772+
773+ expect ( res . body ) . toHaveProperty ( 'error' , 'File not found' ) ;
774+ } ) ;
775+
776+ it ( 'should return 400 for empty file path' , async ( ) => {
777+ const res = await request ( app )
778+ . get ( '/v1/static/' )
779+ . expect ( 400 ) ;
780+
781+ expect ( res . body ) . toHaveProperty ( 'error' , 'File path required' ) ;
782+ } ) ;
783+ } ) ;
784+
785+ describe ( 'Conditional requests (ETag/If-None-Match)' , ( ) => {
786+ it ( 'should return 304 when ETag matches If-None-Match header' , async ( ) => {
787+ const etag = '"abc123"' ;
788+
789+ mockFileExists . mockResolvedValue ( [ true ] ) ;
790+ mockGetMetadata . mockResolvedValue ( [ {
791+ contentType : 'application/json' ,
792+ etag : etag ,
793+ size : 100
794+ } ] ) ;
795+
796+ const res = await request ( app )
797+ . get ( '/v1/static/reports/data.json' )
798+ . set ( 'If-None-Match' , etag )
799+ . expect ( 304 ) ;
800+
801+ // 304 responses have no body
802+ expect ( res . text ) . toEqual ( '' ) ;
803+ } ) ;
804+
805+ it ( 'should return 200 with content when ETag does not match' , async ( ) => {
806+ const fileContent = JSON . stringify ( { data : 'test' } ) ;
807+ const readable = Readable . from ( [ fileContent ] ) ;
808+
809+ mockFileExists . mockResolvedValue ( [ true ] ) ;
810+ mockGetMetadata . mockResolvedValue ( [ {
811+ contentType : 'application/json' ,
812+ etag : '"abc123"' ,
813+ size : fileContent . length
814+ } ] ) ;
815+ mockCreateReadStream . mockReturnValue ( readable ) ;
816+
817+ const res = await request ( app )
818+ . get ( '/v1/static/reports/data.json' )
819+ . set ( 'If-None-Match' , '"different-etag"' )
820+ . expect ( 200 ) ;
821+
822+ expect ( res . headers [ 'etag' ] ) . toEqual ( '"abc123"' ) ;
823+ } ) ;
824+
825+ it ( 'should include ETag in response headers' , async ( ) => {
826+ const fileContent = JSON . stringify ( { data : 'test' } ) ;
827+ const readable = Readable . from ( [ fileContent ] ) ;
828+
829+ mockFileExists . mockResolvedValue ( [ true ] ) ;
830+ mockGetMetadata . mockResolvedValue ( [ {
831+ contentType : 'application/json' ,
832+ etag : '"abc123"' ,
833+ size : fileContent . length
834+ } ] ) ;
835+ mockCreateReadStream . mockReturnValue ( readable ) ;
836+
837+ const res = await request ( app )
838+ . get ( '/v1/static/reports/data.json' )
839+ . expect ( 200 ) ;
840+
841+ expect ( res . headers ) . toHaveProperty ( 'etag' , '"abc123"' ) ;
842+ } ) ;
843+ } ) ;
844+
845+ describe ( 'Error scenarios (GCS failures)' , ( ) => {
846+ it ( 'should handle GCS exists() failure' , async ( ) => {
847+ mockFileExists . mockRejectedValue ( new Error ( 'GCS connection failed' ) ) ;
848+
849+ const res = await request ( app )
850+ . get ( '/v1/static/reports/data.json' )
851+ . expect ( 500 ) ;
852+
853+ expect ( res . body ) . toHaveProperty ( 'error' , 'Failed to retrieve file' ) ;
854+ expect ( res . body ) . toHaveProperty ( 'details' ) ;
855+ } ) ;
856+
857+ it ( 'should handle GCS getMetadata() failure' , async ( ) => {
858+ mockFileExists . mockResolvedValue ( [ true ] ) ;
859+ mockGetMetadata . mockRejectedValue ( new Error ( 'Metadata retrieval failed' ) ) ;
860+
861+ const res = await request ( app )
862+ . get ( '/v1/static/reports/data.json' )
863+ . expect ( 500 ) ;
864+
865+ expect ( res . body ) . toHaveProperty ( 'error' , 'Failed to retrieve file' ) ;
866+ } ) ;
867+
868+ it ( 'should handle stream errors during file read' , async ( ) => {
869+ mockFileExists . mockResolvedValue ( [ true ] ) ;
870+ mockGetMetadata . mockResolvedValue ( [ {
871+ contentType : 'application/json' ,
872+ etag : '"abc123"' ,
873+ size : 100
874+ } ] ) ;
875+
876+ // Create a stream that emits an error after a delay
877+ const errorStream = new Readable ( {
878+ read ( ) {
879+ // Emit error asynchronously
880+ process . nextTick ( ( ) => {
881+ this . destroy ( new Error ( 'Stream read error' ) ) ;
882+ } ) ;
883+ }
884+ } ) ;
885+ mockCreateReadStream . mockReturnValue ( errorStream ) ;
886+
887+ // Use try-catch since stream errors may cause connection issues
888+ try {
889+ const res = await request ( app )
890+ . get ( '/v1/static/reports/data.json' )
891+ . timeout ( 1000 ) ;
892+
893+ // If we get a response, verify error handling
894+ expect ( [ 200 , 500 ] ) . toContain ( res . statusCode ) ;
895+ } catch ( err ) {
896+ // Connection aborted due to stream error is expected behavior
897+ expect ( err . message ) . toMatch ( / a b o r t e d | E C O N N R E S E T | s o c k e t h a n g u p / i) ;
898+ }
899+ } ) ;
900+ } ) ;
901+
902+ describe ( 'MIME type detection' , ( ) => {
903+ it ( 'should detect application/json for .json files' , async ( ) => {
904+ const content = '{"test":true}' ;
905+ const readable = Readable . from ( [ content ] ) ;
906+
907+ mockFileExists . mockResolvedValue ( [ true ] ) ;
908+ mockGetMetadata . mockResolvedValue ( [ { size : content . length } ] ) ;
909+ mockCreateReadStream . mockReturnValue ( readable ) ;
910+
911+ const res = await request ( app )
912+ . get ( '/v1/static/reports/data.json' )
913+ . expect ( 200 ) ;
914+
915+ expect ( res . headers [ 'content-type' ] ) . toContain ( 'application/json' ) ;
916+ } ) ;
917+
918+ it ( 'should detect image/png for .png files' , async ( ) => {
919+ const content = Buffer . from ( [ 0x89 , 0x50 , 0x4E , 0x47 ] ) ; // PNG magic bytes
920+ const readable = Readable . from ( [ content ] ) ;
921+
922+ mockFileExists . mockResolvedValue ( [ true ] ) ;
923+ mockGetMetadata . mockResolvedValue ( [ { size : content . length } ] ) ;
924+ mockCreateReadStream . mockReturnValue ( readable ) ;
925+
926+ const res = await request ( app )
927+ . get ( '/v1/static/reports/chart.png' )
928+ . buffer ( true )
929+ . parse ( ( res , callback ) => {
930+ const chunks = [ ] ;
931+ res . on ( 'data' , chunk => chunks . push ( chunk ) ) ;
932+ res . on ( 'end' , ( ) => callback ( null , Buffer . concat ( chunks ) ) ) ;
933+ } ) ;
934+
935+ expect ( res . statusCode ) . toEqual ( 200 ) ;
936+ expect ( res . headers [ 'content-type' ] ) . toContain ( 'image/png' ) ;
937+ } ) ;
938+
939+ it ( 'should use application/octet-stream for unknown extensions' , async ( ) => {
940+ const content = Buffer . from ( [ 0x00 , 0x01 , 0x02 ] ) ;
941+ const readable = Readable . from ( [ content ] ) ;
942+
943+ mockFileExists . mockResolvedValue ( [ true ] ) ;
944+ mockGetMetadata . mockResolvedValue ( [ { size : content . length } ] ) ;
945+ mockCreateReadStream . mockReturnValue ( readable ) ;
946+
947+ const res = await request ( app )
948+ . get ( '/v1/static/reports/file.xyz' )
949+ . buffer ( true )
950+ . parse ( ( res , callback ) => {
951+ const chunks = [ ] ;
952+ res . on ( 'data' , chunk => chunks . push ( chunk ) ) ;
953+ res . on ( 'end' , ( ) => callback ( null , Buffer . concat ( chunks ) ) ) ;
954+ } ) ;
955+
956+ expect ( res . statusCode ) . toEqual ( 200 ) ;
957+ expect ( res . headers [ 'content-type' ] ) . toContain ( 'application/octet-stream' ) ;
958+ } ) ;
959+ } ) ;
960+ } ) ;
673961} ) ;
0 commit comments