From b815338578d82c331699df0b67976c4a374a5f9a Mon Sep 17 00:00:00 2001 From: FairBid Date: Wed, 26 Aug 2026 17:20:41 +0000 Subject: [PATCH] feat: enforce attachment validation (type, size, filename, malware hook) - Add attachmentValidation.ts with MIME allowlist (PDF only), 10 MB size limit, filename sanitization (path traversal, null bytes, HTML injection, length), PDF magic-byte check, and a swappable malware- scan hook for server-side use - Wire UploadModal.tsx to validateAttachment() on both file-input change and drag-and-drop; show rejection reason in role=alert region - Add /api/attachments/validate route that re-runs the full server-side validator (magic bytes + MIME + size + filename + malware stub) via busboy multipart parsing - Add 65 unit/component tests covering success, failure, boundary, and ordering cases across both client and server validators --- .../LabResults/UploadModal.test.tsx | 222 +++++++++++ src/components/LabResults/UploadModal.tsx | 151 +++++--- src/pages/api/attachments/validate.ts | 205 +++++++++++ src/utils/attachmentValidation.test.ts | 345 ++++++++++++++++++ src/utils/attachmentValidation.ts | 246 +++++++++++++ 5 files changed, 1127 insertions(+), 42 deletions(-) create mode 100644 src/components/LabResults/UploadModal.test.tsx create mode 100644 src/pages/api/attachments/validate.ts create mode 100644 src/utils/attachmentValidation.test.ts create mode 100644 src/utils/attachmentValidation.ts diff --git a/src/components/LabResults/UploadModal.test.tsx b/src/components/LabResults/UploadModal.test.tsx new file mode 100644 index 000000000..dfbd9f19a --- /dev/null +++ b/src/components/LabResults/UploadModal.test.tsx @@ -0,0 +1,222 @@ +/** + * UploadModal – attachment validation tests + * + * RED suite: exercises validation rules that were absent in the original + * implementation. These tests will fail against the unmodified component + * and pass once the fix lands. + */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import UploadModal from './UploadModal'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a synthetic File without triggering real I/O. */ +function makeFile(name: string, size: number, type: string): File { + const content = new Uint8Array(size); + return new File([content], name, { type }); +} + +const PDF_1MB = makeFile('report.pdf', 1 * 1024 * 1024, 'application/pdf'); +const PDF_OVER_LIMIT = makeFile('big.pdf', 11 * 1024 * 1024, 'application/pdf'); +const PDF_EXACT_LIMIT = makeFile('exact.pdf', 10 * 1024 * 1024, 'application/pdf'); +const PNG_FILE = makeFile('image.png', 500 * 1024, 'image/png'); +const EXEC_FILE = makeFile('malware.exe', 100, 'application/octet-stream'); +const DISGUISED_FILE = makeFile('report.pdf', 100, 'application/x-executable'); +const DIRTY_FILENAME = makeFile('../../../etc/passwd', 1024, 'application/pdf'); +const LONG_FILENAME = makeFile('a'.repeat(256) + '.pdf', 1024, 'application/pdf'); +const SCRIPT_FILENAME = makeFile('.pdf', 1024, 'application/pdf'); + +function getFileInput(): HTMLInputElement { + // The hidden file input is identified by its id + return document.getElementById('file-upload') as HTMLInputElement; +} + +// --------------------------------------------------------------------------- +// Rendering helper +// --------------------------------------------------------------------------- +function renderModal() { + const onClose = jest.fn(); + const utils = render(); + return { onClose, ...utils }; +} + +// --------------------------------------------------------------------------- +// RED tests – these characterise MISSING behaviour +// --------------------------------------------------------------------------- + +describe('UploadModal – file type validation', () => { + it('rejects a PNG file with an accessible error message', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [PNG_FILE] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByRole('alert')).toHaveTextContent(/pdf/i); + // File must NOT be accepted + expect(screen.queryByText('report.pdf')).not.toBeInTheDocument(); + }); + + it('rejects an executable disguised as PDF based on MIME type', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [EXEC_FILE] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); + + it('rejects a file whose MIME type is not in the allowlist even if extension is .pdf', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [DISGUISED_FILE] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); +}); + +describe('UploadModal – file size validation', () => { + it('rejects a PDF exceeding the 10 MB limit', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [PDF_OVER_LIMIT] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByRole('alert')).toHaveTextContent(/10\s*mb|size|too large/i); + }); + + it('accepts a PDF that is exactly 10 MB', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [PDF_EXACT_LIMIT] } }); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + // Confirm & Analyse button should now be enabled + expect(screen.getByRole('button', { name: /confirm/i })).not.toBeDisabled(); + }); + + it('accepts a valid 1 MB PDF', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [PDF_1MB] } }); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /confirm/i })).not.toBeDisabled(); + }); +}); + +describe('UploadModal – filename sanitization', () => { + it('rejects a path-traversal filename', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [DIRTY_FILENAME] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByRole('alert')).toHaveTextContent(/filename|invalid/i); + }); + + it('rejects a filename that is excessively long', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [LONG_FILENAME] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); + + it('rejects a filename containing HTML/script injection characters', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [SCRIPT_FILENAME] } }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); +}); + +describe('UploadModal – accessible error UI', () => { + it('error container has role="alert" so screen readers announce it', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [PNG_FILE] } }); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + }); + }); + + it('error container is aria-live="assertive" or role="alert" (implicit assertive)', async () => { + renderModal(); + fireEvent.change(getFileInput(), { target: { files: [PNG_FILE] } }); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + // role="alert" implies aria-live="assertive"; both are acceptable + const live = alert.getAttribute('aria-live'); + expect(live === null || live === 'assertive' || live === 'polite').toBe(true); + }); + }); + + it('clears error when a valid file replaces an invalid one', async () => { + renderModal(); + + // First trigger an error + fireEvent.change(getFileInput(), { target: { files: [PNG_FILE] } }); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + + // Then pick a valid file + fireEvent.change(getFileInput(), { target: { files: [PDF_1MB] } }); + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); +}); + +describe('UploadModal – drag-and-drop validation', () => { + it('rejects a PNG dropped onto the drop zone', async () => { + renderModal(); + + // Use the container div (parent of the input) + const dropArea = document + .querySelector('.border-dashed') as HTMLElement; + + fireEvent.drop(dropArea, { + dataTransfer: { files: [PNG_FILE] }, + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); + + it('rejects an oversized PDF dropped onto the drop zone', async () => { + renderModal(); + const dropArea = document.querySelector('.border-dashed') as HTMLElement; + + fireEvent.drop(dropArea, { + dataTransfer: { files: [PDF_OVER_LIMIT] }, + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByRole('alert')).toHaveTextContent(/10\s*mb|size|too large/i); + }); +}); + +describe('UploadModal – loading / empty state', () => { + it('shows the upload area when no file is selected', () => { + renderModal(); + expect(screen.getByText(/drag & drop your pdf/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /confirm/i })).toBeDisabled(); + }); +}); diff --git a/src/components/LabResults/UploadModal.tsx b/src/components/LabResults/UploadModal.tsx index cb834467e..2a37c9203 100644 --- a/src/components/LabResults/UploadModal.tsx +++ b/src/components/LabResults/UploadModal.tsx @@ -1,10 +1,19 @@ -import React, { useState, useCallback } from 'react'; import { UploadCloud, File, X, CheckCircle2, AlertCircle } from 'lucide-react'; +import React, { useState, useCallback, useId } from 'react'; + + +import { + validateAttachment, + ALLOWED_EXTENSIONS_LABEL, + MAX_FILE_SIZE_BYTES, +} from '@/utils/attachmentValidation'; interface UploadModalProps { onClose: () => void; } +const MAX_FILE_SIZE_MB = MAX_FILE_SIZE_BYTES / (1024 * 1024); + export default function UploadModal({ onClose }: UploadModalProps) { const [isDragActive, setIsDragActive] = useState(false); const [selectedFile, setSelectedFile] = useState(null); @@ -12,6 +21,28 @@ export default function UploadModal({ onClose }: UploadModalProps) { const [isSuccess, setIsSuccess] = useState(false); const [error, setError] = useState(null); + // Stable id for the error region so the input can reference it via aria-describedby + const errorId = useId(); + + // ------------------------------------------------------------------ + // File selection handler – shared by click and drag-and-drop + // ------------------------------------------------------------------ + const handleFile = useCallback((file: File) => { + setError(null); + + const result = validateAttachment(file); + if (!result.valid) { + setError(result.error ?? 'This file cannot be uploaded.'); + setSelectedFile(null); + return; + } + + setSelectedFile(file); + }, []); + + // ------------------------------------------------------------------ + // Drag handlers + // ------------------------------------------------------------------ const handleDrag = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -22,41 +53,42 @@ export default function UploadModal({ onClose }: UploadModalProps) { } }, []); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragActive(false); - setError(null); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); - const files = e.dataTransfer.files; - if (files && files[0]) { - const file = files[0]; - if (file.type === 'application/pdf') { - setSelectedFile(file); - } else { - setError('Please upload a PDF file.'); + const files = e.dataTransfer.files; + if (files && files[0]) { + handleFile(files[0]); } - } - }, []); + }, + [handleFile], + ); - const handleFileChange = (e: React.ChangeEvent) => { - setError(null); - if (e.target.files && e.target.files[0]) { - const file = e.target.files[0]; - if (file.type === 'application/pdf') { - setSelectedFile(file); - } else { - setError('Please upload a PDF file.'); + // ------------------------------------------------------------------ + // Input change handler + // ------------------------------------------------------------------ + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + handleFile(e.target.files[0]); } - } - }; + }, + [handleFile], + ); - const handleUpload = () => { + // ------------------------------------------------------------------ + // Upload handler (calls the real API in production) + // ------------------------------------------------------------------ + const handleUpload = useCallback(() => { if (!selectedFile) return; setIsUploading(true); setError(null); // Simulate upload delay and processing + // In production: replace with uploadLabReport(petId, selectedFile) setTimeout(() => { setIsUploading(false); setIsSuccess(true); @@ -64,7 +96,19 @@ export default function UploadModal({ onClose }: UploadModalProps) { onClose(); }, 2000); }, 1500); - }; + }, [selectedFile, onClose]); + + // ------------------------------------------------------------------ + // Derived state for dropzone styling + // ------------------------------------------------------------------ + const dropzoneClass = [ + 'relative border-2 border-dashed rounded-2xl p-8 sm:p-10 text-center transition-all duration-200', + isDragActive + ? 'border-blue-500 bg-blue-50 scale-[1.02]' + : error + ? 'border-red-300 bg-red-50' + : 'border-gray-200 bg-gray-50 hover:bg-gray-100', + ].join(' '); return (
+ {/* Close button */} + {/* Title */}

Upload Results

- Upload your pet's official lab report PDF to automatically extract and store results - securely on the blockchain. + Upload your pet's official lab report {ALLOWED_EXTENSIONS_LABEL} to automatically + extract and store results securely on the blockchain. +
+ + Maximum size: {MAX_FILE_SIZE_MB} MB. Only {ALLOWED_EXTENSIONS_LABEL} files accepted. +

{isSuccess ? ( + /* ---- Success state ---- */
@@ -105,24 +157,24 @@ export default function UploadModal({ onClose }: UploadModalProps) {
) : (
+ {/* ---- Drop zone ---- */}
{selectedFile ? ( @@ -142,7 +194,7 @@ export default function UploadModal({ onClose }: UploadModalProps) {
-

Drag & drop your PDF

+

Drag & drop your PDF

or click to browse

@@ -151,14 +203,28 @@ export default function UploadModal({ onClose }: UploadModalProps) {
+ {/* ---- Accessible error region ---- */} + {/* + role="alert" implies aria-live="assertive", which causes + screen readers to announce the message immediately when it + appears. The region is always rendered in the DOM when + there is an error so the announcement fires correctly. + */} {error && ( -
- + )} + {/* ---- Submit button ---- */}