Skip to content

Commit 3c367fa

Browse files
committed
feat: add tests for static file retrieval and error handling in API routes
1 parent 6a6ee58 commit 3c367fa

File tree

1 file changed

+308
-0
lines changed

1 file changed

+308
-0
lines changed

src/__tests__/routes.test.js

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
import request from 'supertest';
22
import { jest } from '@jest/globals';
3+
import { Readable } from 'stream';
4+
5+
// Mock the GCS Storage module
6+
const mockFileExists = jest.fn();
7+
const mockGetMetadata = jest.fn();
8+
const mockCreateReadStream = jest.fn();
9+
const mockFile = jest.fn(() => ({
10+
exists: mockFileExists,
11+
getMetadata: mockGetMetadata,
12+
createReadStream: mockCreateReadStream
13+
}));
14+
const mockBucket = jest.fn(() => ({
15+
file: mockFile
16+
}));
17+
18+
jest.unstable_mockModule('@google-cloud/storage', () => ({
19+
Storage: jest.fn(() => ({
20+
bucket: mockBucket
21+
}))
22+
}));
323

424
// Mock the entire utils/db module using ESM-compatible mocking
525
jest.unstable_mockModule('../utils/db.js', () => {
@@ -393,4 +413,292 @@ describe('API Routes', () => {
393413
expect(res.headers['access-control-allow-methods']).toContain('POST');
394414
});
395415
});
416+
417+
describe('GET /v1/static/*', () => {
418+
beforeEach(() => {
419+
// Reset all mocks before each test
420+
mockFileExists.mockReset();
421+
mockGetMetadata.mockReset();
422+
mockCreateReadStream.mockReset();
423+
mockFile.mockClear();
424+
mockBucket.mockClear();
425+
});
426+
427+
describe('Valid file requests', () => {
428+
it('should return file content for valid path', async () => {
429+
const fileContent = JSON.stringify({ data: 'test' });
430+
const readable = Readable.from([fileContent]);
431+
432+
mockFileExists.mockResolvedValue([true]);
433+
mockGetMetadata.mockResolvedValue([{
434+
contentType: 'application/json',
435+
etag: '"abc123"',
436+
size: fileContent.length
437+
}]);
438+
mockCreateReadStream.mockReturnValue(readable);
439+
440+
const res = await request(app)
441+
.get('/v1/static/reports/2024/data.json')
442+
.expect(200);
443+
444+
expect(res.headers['content-type']).toContain('application/json');
445+
expect(res.headers['cache-control']).toContain('public');
446+
expect(res.headers['access-control-allow-origin']).toEqual('*');
447+
});
448+
449+
it('should infer MIME type from file extension when not in metadata', async () => {
450+
const fileContent = '<html></html>';
451+
const readable = Readable.from([fileContent]);
452+
453+
mockFileExists.mockResolvedValue([true]);
454+
mockGetMetadata.mockResolvedValue([{
455+
etag: '"abc123"',
456+
size: fileContent.length
457+
}]);
458+
mockCreateReadStream.mockReturnValue(readable);
459+
460+
const res = await request(app)
461+
.get('/v1/static/reports/page.html')
462+
.expect(200);
463+
464+
expect(res.headers['content-type']).toContain('text/html');
465+
});
466+
467+
it('should handle CORS preflight requests', async () => {
468+
const res = await request(app)
469+
.options('/v1/static/reports/data.json')
470+
.set('Origin', 'http://example.com')
471+
.set('Access-Control-Request-Method', 'GET')
472+
.set('Access-Control-Request-Headers', 'Content-Type');
473+
474+
expect(res.statusCode).toEqual(204);
475+
expect(res.headers['access-control-allow-origin']).toEqual('*');
476+
});
477+
});
478+
479+
describe('Invalid file paths (directory traversal attempts)', () => {
480+
it('should reject paths containing double dot sequences', async () => {
481+
// Test with '..' embedded in the path that won't be normalized away
482+
const res = await request(app)
483+
.get('/v1/static/reports/..hidden/passwd')
484+
485+
expect(res.body).toHaveProperty('error', 'Invalid file path');
486+
});
487+
488+
it('should reject paths with double slashes', async () => {
489+
const res = await request(app)
490+
.get('/v1/static/reports//data.json')
491+
.expect(400);
492+
493+
expect(res.body).toHaveProperty('error', 'Invalid file path');
494+
});
495+
496+
it('should reject paths with encoded double dots', async () => {
497+
// URL-encoded '..' = %2e%2e
498+
mockFileExists.mockResolvedValue([false]); // Will be checked after validation
499+
500+
const res = await request(app)
501+
.get('/v1/static/reports/%2e%2e/secret/passwd');
502+
503+
// Should either be rejected as invalid or not found
504+
expect([400, 404]).toContain(res.statusCode);
505+
});
506+
});
507+
508+
describe('Non-existent files (404 handling)', () => {
509+
it('should return 404 for non-existent files', async () => {
510+
mockFileExists.mockResolvedValue([false]);
511+
512+
const res = await request(app)
513+
.get('/v1/static/reports/nonexistent.json')
514+
.expect(404);
515+
516+
expect(res.body).toHaveProperty('error', 'File not found');
517+
});
518+
519+
it('should return 400 for empty file path', async () => {
520+
const res = await request(app)
521+
.get('/v1/static/')
522+
.expect(400);
523+
524+
expect(res.body).toHaveProperty('error', 'File path required');
525+
});
526+
});
527+
528+
describe('Conditional requests (ETag/If-None-Match)', () => {
529+
it('should return 304 when ETag matches If-None-Match header', async () => {
530+
const etag = '"abc123"';
531+
532+
mockFileExists.mockResolvedValue([true]);
533+
mockGetMetadata.mockResolvedValue([{
534+
contentType: 'application/json',
535+
etag: etag,
536+
size: 100
537+
}]);
538+
539+
const res = await request(app)
540+
.get('/v1/static/reports/data.json')
541+
.set('If-None-Match', etag)
542+
.expect(304);
543+
544+
// 304 responses have no body
545+
expect(res.text).toEqual('');
546+
});
547+
548+
it('should return 200 with content when ETag does not match', async () => {
549+
const fileContent = JSON.stringify({ data: 'test' });
550+
const readable = Readable.from([fileContent]);
551+
552+
mockFileExists.mockResolvedValue([true]);
553+
mockGetMetadata.mockResolvedValue([{
554+
contentType: 'application/json',
555+
etag: '"abc123"',
556+
size: fileContent.length
557+
}]);
558+
mockCreateReadStream.mockReturnValue(readable);
559+
560+
const res = await request(app)
561+
.get('/v1/static/reports/data.json')
562+
.set('If-None-Match', '"different-etag"')
563+
.expect(200);
564+
565+
expect(res.headers['etag']).toEqual('"abc123"');
566+
});
567+
568+
it('should include ETag in response headers', async () => {
569+
const fileContent = JSON.stringify({ data: 'test' });
570+
const readable = Readable.from([fileContent]);
571+
572+
mockFileExists.mockResolvedValue([true]);
573+
mockGetMetadata.mockResolvedValue([{
574+
contentType: 'application/json',
575+
etag: '"abc123"',
576+
size: fileContent.length
577+
}]);
578+
mockCreateReadStream.mockReturnValue(readable);
579+
580+
const res = await request(app)
581+
.get('/v1/static/reports/data.json')
582+
.expect(200);
583+
584+
expect(res.headers).toHaveProperty('etag', '"abc123"');
585+
});
586+
});
587+
588+
describe('Error scenarios (GCS failures)', () => {
589+
it('should handle GCS exists() failure', async () => {
590+
mockFileExists.mockRejectedValue(new Error('GCS connection failed'));
591+
592+
const res = await request(app)
593+
.get('/v1/static/reports/data.json')
594+
.expect(500);
595+
596+
expect(res.body).toHaveProperty('error', 'Failed to retrieve file');
597+
expect(res.body).toHaveProperty('details');
598+
});
599+
600+
it('should handle GCS getMetadata() failure', async () => {
601+
mockFileExists.mockResolvedValue([true]);
602+
mockGetMetadata.mockRejectedValue(new Error('Metadata retrieval failed'));
603+
604+
const res = await request(app)
605+
.get('/v1/static/reports/data.json')
606+
.expect(500);
607+
608+
expect(res.body).toHaveProperty('error', 'Failed to retrieve file');
609+
});
610+
611+
it('should handle stream errors during file read', async () => {
612+
mockFileExists.mockResolvedValue([true]);
613+
mockGetMetadata.mockResolvedValue([{
614+
contentType: 'application/json',
615+
etag: '"abc123"',
616+
size: 100
617+
}]);
618+
619+
// Create a stream that emits an error after a delay
620+
const errorStream = new Readable({
621+
read() {
622+
// Emit error asynchronously
623+
process.nextTick(() => {
624+
this.destroy(new Error('Stream read error'));
625+
});
626+
}
627+
});
628+
mockCreateReadStream.mockReturnValue(errorStream);
629+
630+
// Use try-catch since stream errors may cause connection issues
631+
try {
632+
const res = await request(app)
633+
.get('/v1/static/reports/data.json')
634+
.timeout(1000);
635+
636+
// If we get a response, verify error handling
637+
expect([200, 500]).toContain(res.statusCode);
638+
} catch (err) {
639+
// Connection aborted due to stream error is expected behavior
640+
expect(err.message).toMatch(/aborted|ECONNRESET|socket hang up/i);
641+
}
642+
});
643+
});
644+
645+
describe('MIME type detection', () => {
646+
it('should detect application/json for .json files', async () => {
647+
const content = '{"test":true}';
648+
const readable = Readable.from([content]);
649+
650+
mockFileExists.mockResolvedValue([true]);
651+
mockGetMetadata.mockResolvedValue([{ size: content.length }]);
652+
mockCreateReadStream.mockReturnValue(readable);
653+
654+
const res = await request(app)
655+
.get('/v1/static/reports/data.json')
656+
.expect(200);
657+
658+
expect(res.headers['content-type']).toContain('application/json');
659+
});
660+
661+
it('should detect image/png for .png files', async () => {
662+
const content = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes
663+
const readable = Readable.from([content]);
664+
665+
mockFileExists.mockResolvedValue([true]);
666+
mockGetMetadata.mockResolvedValue([{ size: content.length }]);
667+
mockCreateReadStream.mockReturnValue(readable);
668+
669+
const res = await request(app)
670+
.get('/v1/static/reports/chart.png')
671+
.buffer(true)
672+
.parse((res, callback) => {
673+
const chunks = [];
674+
res.on('data', chunk => chunks.push(chunk));
675+
res.on('end', () => callback(null, Buffer.concat(chunks)));
676+
});
677+
678+
expect(res.statusCode).toEqual(200);
679+
expect(res.headers['content-type']).toContain('image/png');
680+
});
681+
682+
it('should use application/octet-stream for unknown extensions', async () => {
683+
const content = Buffer.from([0x00, 0x01, 0x02]);
684+
const readable = Readable.from([content]);
685+
686+
mockFileExists.mockResolvedValue([true]);
687+
mockGetMetadata.mockResolvedValue([{ size: content.length }]);
688+
mockCreateReadStream.mockReturnValue(readable);
689+
690+
const res = await request(app)
691+
.get('/v1/static/reports/file.xyz')
692+
.buffer(true)
693+
.parse((res, callback) => {
694+
const chunks = [];
695+
res.on('data', chunk => chunks.push(chunk));
696+
res.on('end', () => callback(null, Buffer.concat(chunks)));
697+
});
698+
699+
expect(res.statusCode).toEqual(200);
700+
expect(res.headers['content-type']).toContain('application/octet-stream');
701+
});
702+
});
703+
});
396704
});

0 commit comments

Comments
 (0)