Skip to content
216 changes: 215 additions & 1 deletion app/api/student/resume/tests/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@

// Build the handler request directly from a real File/FormData so the file bytes are
// preserved (the multipart transport itself is Next.js's responsibility, not this route's).
function makeUploadRequest(content: string | number[], type: string, name = 'resume.pdf'): Request {
function makeUploadRequest(
content: string | number[],
type: string,
name = 'resume.pdf',
size?: number
): Request {
const data = typeof content === 'string' ? content : new Uint8Array(content);
const file = new File([data], name, { type });
// Override size if specified
if (size !== undefined) {
Object.defineProperty(file, 'size', { value: size, writable: false });
}
const form = new FormData();
form.append('resume', file);

Expand All @@ -30,6 +39,7 @@
vi.clearAllMocks();
});

// Basic validation tests
it('returns 400 for a disallowed mime type', async () => {
const response = await POST(makeUploadRequest('hello', 'text/html', 'note.html'));
const body = await response.json();
Expand Down Expand Up @@ -59,4 +69,208 @@
expect(body.success).toBe(true);
expect(body.data).toBeDefined();
});

// File size validation tests
it('returns 400 when file is too small', async () => {
const response = await POST(makeUploadRequest('123', 'application/pdf', 'tiny.pdf', 3));
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toContain('too small');
});

it('returns 400 when file size exceeds limit', async () => {
const largeContent = 'A'.repeat(6 * 1024 * 1024); // 6MB
const response = await POST(
makeUploadRequest(
'%PDF-1.7\n' + largeContent,
'application/pdf',
'large.pdf',
6 * 1024 * 1024
)
);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toContain('exceeds');
});

// File extension validation tests
it('returns 400 when file extension does not match MIME type', async () => {
const response = await POST(
makeUploadRequest('%PDF-1.7\ncontent', 'application/pdf', 'document.docx')
);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toContain('extension does not match');
});

it('accepts valid PDF with correct extension', async () => {
const response = await POST(
makeUploadRequest('%PDF-1.7\nJohn Doe\njohn@example.com', 'application/pdf', 'resume.pdf')
);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
});

it('accepts valid DOCX with correct extension', async () => {
const mockValidDocx = Buffer.alloc(200);
mockValidDocx.writeUInt32LE(0x04034b50, 0);
mockValidDocx.writeUInt32LE(0x02014b50, 50);
mockValidDocx.writeUInt32LE(100, 50 + 20);
mockValidDocx.writeUInt32LE(200, 50 + 24);
mockValidDocx.writeUInt16LE(4, 50 + 28);

const response = await POST(
makeUploadRequest(
Array.from(mockValidDocx),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'resume.docx'
)
);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
});

// Zip bomb detection tests
it('rejects a DOCX/ZIP file containing a zip bomb (high decompression ratio)', async () => {
const mockZipBomb = Buffer.alloc(200);
mockZipBomb.writeUInt32LE(0x04034b50, 0); // Local Header signature
mockZipBomb.writeUInt32LE(0x02014b50, 50); // Central Directory signature
mockZipBomb.writeUInt32LE(10, 50 + 20); // Compressed size = 10
mockZipBomb.writeUInt32LE(10000, 50 + 24); // Uncompressed size = 10,000 (ratio = 1000x > 50x limit)
mockZipBomb.writeUInt16LE(4, 50 + 28); // File name length = 4

const response = await POST(
makeUploadRequest(
Array.from(mockZipBomb),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'bomb.docx'
)
);
const body = await response.json();

expect(response.status).toBe(422);
expect(body.success).toBe(false);
expect(body.error).toContain('Failed to parse resume');
});

it('accepts a valid DOCX structure with normal decompression ratio', async () => {
const mockValidDocx = Buffer.alloc(200);
mockValidDocx.writeUInt32LE(0x04034b50, 0); // Local Header signature
mockValidDocx.writeUInt32LE(0x02014b50, 50); // Central Directory signature
mockValidDocx.writeUInt32LE(100, 50 + 20); // Compressed size = 100
mockValidDocx.writeUInt32LE(200, 50 + 24); // Uncompressed size = 200 (ratio = 2x)
mockValidDocx.writeUInt16LE(4, 50 + 28); // File name length = 4

const response = await POST(
makeUploadRequest(
Array.from(mockValidDocx),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'valid.docx'
)
);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
});

// Filename sanitization tests
it('sanitizes filenames with path traversal attempts', async () => {
const response = await POST(
makeUploadRequest('%PDF-1.7\ncontent', 'application/pdf', '../../../etc/passwd.pdf')
);
const body = await response.json();

// Should succeed but with sanitized filename
expect(response.status).toBe(200);
expect(body.fileName).not.toContain('..');
expect(body.fileName).not.toContain('/');
});

it('sanitizes filenames with special characters', async () => {
const response = await POST(
makeUploadRequest(
'%PDF-1.7\ncontent',
'application/pdf',
'resume<script>alert(1)</script>.pdf'
)
);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.fileName).not.toContain('<');
expect(body.fileName).not.toContain('>');
});

// Encrypted PDF detection tests
it('rejects encrypted PDFs', async () => {
// Create a PDF with encryption marker
const encryptedPdf = '%PDF-1.7\n/Encrypt\nsome content';
const response = await POST(
makeUploadRequest(encryptedPdf, 'application/pdf', 'encrypted.pdf')
);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toContain('Encrypted');
});

// Timeout tests
it('returns 422 if the resume parser times out', async () => {
const resumeParser = await import('@/lib/resume-parser');
const spy = vi
.spyOn(resumeParser, 'parseResume')
.mockRejectedValue(new Error('Parser timeout: parsing took longer than 8 seconds.'));

const response = await POST(
makeUploadRequest('%PDF-1.7\nJohn Doe\njohn@example.com', 'application/pdf')
);
const body = await response.json();

expect(response.status).toBe(422);
expect(body.success).toBe(false);
expect(body.error).toContain('Failed to parse resume');

spy.mockRestore();
});

// Invalid structure tests
it('rejects PDFs with invalid structure', async () => {
// PDF header but mostly null bytes
const corruptedPdf = Buffer.alloc(1024);
corruptedPdf.write('%PDF-1.7', 0);
// Rest is null bytes

const response = await POST(
makeUploadRequest(Array.from(corruptedPdf), 'application/pdf', 'corrupted.pdf')
);
const body = await response.json();

expect(response.status).toBe(422);

Check failure on line 256 in app/api/student/resume/tests/upload.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

app/api/student/resume/tests/upload.test.ts > POST /api/student/resume/upload > rejects PDFs with invalid structure

AssertionError: expected 200 to be 422 // Object.is equality - Expected + Received - 422 + 200 ❯ app/api/student/resume/tests/upload.test.ts:256:29
expect(body.success).toBe(false);
});

it('rejects DOCX with invalid structure', async () => {
// Invalid ZIP header
const invalidDocx = Buffer.from([0x00, 0x00, 0x00, 0x00]);

const response = await POST(
makeUploadRequest(
Array.from(invalidDocx),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'invalid.docx'
)
);
const body = await response.json();

expect(response.status).toBe(422);

Check failure on line 273 in app/api/student/resume/tests/upload.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

app/api/student/resume/tests/upload.test.ts > POST /api/student/resume/upload > rejects DOCX with invalid structure

AssertionError: expected 400 to be 422 // Object.is equality - Expected + Received - 422 + 400 ❯ app/api/student/resume/tests/upload.test.ts:273:29
expect(body.success).toBe(false);
});
});
84 changes: 81 additions & 3 deletions app/api/student/resume/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ import { getClientIp } from '@/utils/getClientIp';

const uploadLimiter = new RateLimiter(10, 60000);

/**
* Sanitizes a filename to prevent directory traversal and other attacks.
*/
function sanitizeFilename(filename: string): string {
// Remove any path separators and dangerous characters
const sanitized = filename
.replace(/[\\/]/g, '_') // Replace path separators
.replace(/\.\./g, '_') // Replace directory traversal attempts
.replace(/[^\w.\-]/g, '_') // Replace any other non-alphanumeric characters
.toLowerCase();

// Ensure filename doesn't start with a dot (hidden files)
const cleanName = sanitized.startsWith('.') ? '_' + sanitized.slice(1) : sanitized;

// Limit filename length
return cleanName.substring(0, 255);
}

/**
* Validates file extension matches the MIME type.
*/
function validateFileExtension(filename: string, mimeType: string): boolean {
const ext = filename.toLowerCase().split('.').pop();
const validExtensions: Record<string, string[]> = {
'application/pdf': ['pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
};

const allowedExts = validExtensions[mimeType];
if (!allowedExts) return false;

return allowedExts.includes(ext || '');
}

export async function POST(req: Request) {
const ip = getClientIp(req);

Expand All @@ -34,6 +68,7 @@ export async function POST(req: Request) {
return NextResponse.json({ success: false, error: 'No resume file provided' }, { status: 400 });
}

// Validate MIME type
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
return NextResponse.json(
{
Expand All @@ -44,11 +79,35 @@ export async function POST(req: Request) {
);
}

// Validate file size
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{
success: false,
error: 'File size exceeds the 5MB limit.',
error: `File size exceeds the ${MAX_FILE_SIZE / (1024 * 1024)}MB limit.`,
},
{ status: 400 }
);
}

// Validate file size is not zero or too small
if (file.size < 10) {
return NextResponse.json(
{ success: false, error: 'File is too small to be a valid document.' },
{ status: 400 }
);
}

// Sanitize filename
const sanitizedFileName = sanitizeFilename(file.name);

// Validate file extension matches MIME type
if (!validateFileExtension(sanitizedFileName, file.type)) {
return NextResponse.json(
{
success: false,
error:
'File extension does not match the file type. Please ensure the filename ends with the correct extension.',
},
{ status: 400 }
);
Expand All @@ -57,6 +116,7 @@ export async function POST(req: Request) {
try {
const buffer = Buffer.from(await file.arrayBuffer());

// Validate file signature (magic bytes)
if (!hasValidFileSignature(buffer, file.type)) {
return NextResponse.json(
{
Expand All @@ -68,15 +128,33 @@ export async function POST(req: Request) {
);
}

// Additional security: Check for encrypted/password-protected PDFs
if (file.type === 'application/pdf') {
const pdfContent = buffer.toString('utf-8');
if (pdfContent.includes('/Encrypt') || pdfContent.includes('encrypt')) {
return NextResponse.json(
{
success: false,
error: 'Encrypted or password-protected PDFs are not supported.',
},
{ status: 400 }
);
}
}

const parsed = await parseResume(buffer, file.type);

return NextResponse.json({
success: true,
data: parsed,
fileName: file.name,
fileName: sanitizedFileName,
});
} catch (error) {
console.error('Error parsing resume:', error);
// Log error for monitoring (in production, use proper logging service)
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Error parsing resume:', errorMessage);

// Return generic error message to user (don't expose internal details)
return NextResponse.json(
{ success: false, error: 'Failed to parse resume. Please enter your details manually.' },
{ status: 422 }
Expand Down
12 changes: 8 additions & 4 deletions lib/resume-parser.binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@

describe('resume-parser binary document parsing', () => {
it('correctly uses pdf-parse when parsing a valid PDF buffer', async () => {
// A mock PDF buffer starts with "%PDF"
const buffer = Buffer.from('%PDF-1.4\nSome binary content');
// A valid PDF buffer starts with "%PDF" and has proper structure
const pdfHeader = '%PDF-1.4\n';
const padding = ' '.repeat(100); // Ensure minimum size
const buffer = Buffer.from(pdfHeader + padding + 'Some binary content');
const result = await parseResume(buffer, 'application/pdf');

expect(result.name).toBe('John Doe');

Check failure on line 32 in lib/resume-parser.binary.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

lib/resume-parser.binary.test.ts > resume-parser binary document parsing > correctly uses pdf-parse when parsing a valid PDF buffer

AssertionError: expected 'Some' to be 'John Doe' // Object.is equality Expected: "John Doe" Received: "Some" ❯ lib/resume-parser.binary.test.ts:32:25
expect(result.email).toBe('john.doe@example.com');
expect(result.phone).toBe('+1 234-567-8901');
expect(result.skills).toContain('TypeScript');
Expand All @@ -36,8 +38,10 @@
});

it('correctly uses mammoth when parsing a valid DOCX buffer', async () => {
// A mock DOCX/ZIP container starts with "PK"
const buffer = Buffer.from('PK\x03\x04\nSome binary docx zip content');
// A valid DOCX/ZIP buffer starts with "PK\x03\x04" and has proper structure
const docxHeader = 'PK\x03\x04';
const padding = 'x'.repeat(100); // Ensure minimum size
const buffer = Buffer.from(docxHeader + padding + '\nSome binary docx zip content');
const result = await parseResume(
buffer,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
Expand Down
Loading
Loading