Skip to content

Commit 9df1b6e

Browse files
Merge branch 'main' into development
2 parents 6ce30ce + 686e4e9 commit 9df1b6e

File tree

3 files changed

+297
-1
lines changed

3 files changed

+297
-1
lines changed

src/__tests__/routes.test.js

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/aborted|ECONNRESET|socket hang up/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
});

terraform/modules/run-service/main.tf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,11 @@ resource "google_cloud_run_v2_service_iam_member" "api_gw_variable_service_accou
9292
role = "roles/run.invoker"
9393
member = "serviceAccount:${var.service_account_api_gateway}"
9494
}
95+
96+
resource "google_cloud_run_v2_service_iam_member" "allow_unauthenticated" {
97+
project = var.project
98+
location = var.region
99+
name = data.google_cloud_run_service.run-service.name
100+
role = "roles/run.invoker"
101+
member = "allUsers"
102+
}

terraform/modules/run-service/variables.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ variable "available_cpu" {
3333
}
3434
variable "ingress_settings" {
3535
type = string
36-
default = "ALLOW_ALL"
36+
default = "ALLOW_INTERNAL_AND_GCLB"
3737
description = "String value that controls what traffic can reach the function. Allowed values are ALLOW_ALL, ALLOW_INTERNAL_AND_GCLB and ALLOW_INTERNAL_ONLY. Check ingress documentation to see the impact of each settings value. Changes to this field will recreate the cloud function."
3838
}
3939
variable "vpc_connector_egress_settings" {

0 commit comments

Comments
 (0)