diff --git a/src/app/(dashboard)/claims/[id]/page.tsx b/src/app/(dashboard)/claims/[id]/page.tsx index e56263c5..319c90f6 100644 --- a/src/app/(dashboard)/claims/[id]/page.tsx +++ b/src/app/(dashboard)/claims/[id]/page.tsx @@ -20,6 +20,25 @@ export default function ClaimDetailPage() { const [stakeAmount, setStakeAmount] = useState(0); const [claimNotFound, setClaimNotFound] = useState(false); + const [claim, setClaim] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (params.id) { + setIsLoading(true); + getClaimById(params.id) + .then((data) => { + setClaim(data); + setIsLoading(false); + }) + .catch((err) => { + if (err.message === 'CLAIM_NOT_FOUND') { + setClaimNotFound(true); + } + setIsLoading(false); + }); + } + }, [params.id]); const handleStakeChange = (stake: string) => { const value = parseFloat(stake) || 0; @@ -298,5 +317,4 @@ export default function ClaimDetailPage() { ); -} - +} \ No newline at end of file diff --git a/src/components/features/claim-verification/EvidenceViewer.tsx b/src/components/features/claim-verification/EvidenceViewer.tsx index 80d342ca..c1662923 100644 --- a/src/components/features/claim-verification/EvidenceViewer.tsx +++ b/src/components/features/claim-verification/EvidenceViewer.tsx @@ -138,4 +138,4 @@ export function EvidenceViewer({ ); } -export default EvidenceViewer; +export default EvidenceViewer; \ No newline at end of file diff --git a/src/components/features/claim-verification/__tests__/EvidenceViewer.new.test.tsx b/src/components/features/claim-verification/__tests__/EvidenceViewer.new.test.tsx new file mode 100644 index 00000000..3108a116 --- /dev/null +++ b/src/components/features/claim-verification/__tests__/EvidenceViewer.new.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { EvidenceViewer } from '../EvidenceViewer'; +import type { Evidence } from '@/app/types/claim'; + +// Mock the lucide-react icons to avoid rendering issues in tests +jest.mock('lucide-react', () => ({ + AlertCircle: () =>
, + ImageOff: () =>
, + Link2: () =>
, + FileText: () =>
, +})); + +describe('EvidenceViewer - new functionality', () => { + const mockClaimId = 'claim-123'; + + it('shows no evidence state when evidence array is empty', () => { + render(); + expect(screen.getByTestId('no-evidence-state')).toBeInTheDocument(); + expect(screen.getByText('No evidence submitted for this claim')).toBeInTheDocument(); + }); + + it('renders valid image with loading state', async () => { + const validEvidence: Evidence[] = [ + { + id: '1', + type: 'image', + value: '/test-image.jpg', + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + + // Skeleton should be present initially (loading state) + expect(screen.getByTestId('image-skeleton-0')).toBeInTheDocument(); + + // After image loads, skeleton should be hidden and image visible + const img = screen.getByAltText('Evidence image 1'); + fireEvent.load(img); + + await waitFor(() => { + expect(screen.queryByTestId('image-skeleton-0')).not.toBeInTheDocument(); + }); + }); + + it('shows error state when image fails to load', async () => { + const brokenImageEvidence: Evidence[] = [ + { + id: '1', + type: 'image', + value: '/broken-image.jpg', + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + + const img = screen.getByAltText('Evidence image 1'); + fireEvent.error(img); + + await waitFor(() => { + expect(screen.getByTestId('image-error-0')).toBeInTheDocument(); + expect(screen.getByText('Failed to load image')).toBeInTheDocument(); + }); + }); + + it('blocks invalid URLs for security', () => { + const maliciousEvidence: Evidence[] = [ + { + id: '1', + type: 'link', + value: 'javascript:alert(1)', + createdAt: '2024-01-01T00:00:00Z', + }, + { + id: '2', + type: 'image', + value: 'data:image/png;base64,invalid', + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + + // Should show security error for invalid link + expect(screen.getByText('Invalid link source blocked for security')).toBeInTheDocument(); + + // Should show security error for invalid image + expect(screen.getByText('Invalid image source')).toBeInTheDocument(); + }); + + it('allows valid HTTPS links and blocks invalid schemes', () => { + const mixedEvidence: Evidence[] = [ + { + id: '1', + type: 'link', + value: 'https://trusted-source.com/evidence.pdf', + createdAt: '2024-01-01T00:00:00Z', + }, + { + id: '2', + type: 'link', + value: 'http://insecure-source.com', // HTTP should be blocked for external links + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + + // Valid HTTPS link should render normally + expect(screen.getByText('https://trusted-source.com/evidence.pdf')).toBeInTheDocument(); + + // Insecure HTTP link should be blocked + expect(screen.getByText('Invalid link source blocked for security')).toBeInTheDocument(); + }); + + it('renders text evidence properly', () => { + const textEvidence: Evidence[] = [ + { + id: '1', + type: 'text', + value: 'Witness statement about the incident', + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + expect(screen.getByText('Witness statement about the incident')).toBeInTheDocument(); + expect(screen.getByTestId('file-text')).toBeInTheDocument(); + }); + + it('handles video evidence with warning state', () => { + const videoEvidence: Evidence[] = [ + { + id: '1', + type: 'video', + value: 'https://example.com/video.mp4', + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + expect(screen.getByText('Video evidence requires manual verification - link available upon request')).toBeInTheDocument(); + }); + + it('validates image file extensions', () => { + const invalidFormatEvidence: Evidence[] = [ + { + id: '1', + type: 'image', + value: 'https://example.com/image.exe', // Executable, not an image + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + expect(screen.getByText('Invalid or unsupported image format')).toBeInTheDocument(); + }); + + it('maintains accessibility attributes', () => { + const testEvidence: Evidence[] = [ + { + id: '1', + type: 'image', + value: '/test.jpg', + createdAt: '2024-01-01T00:00:00Z', + }, + ]; + + render(); + + const toggleButton = screen.getByRole('button', { name: /evidence/i }); + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + expect(toggleButton).toHaveAttribute('aria-controls', 'evidence-content'); + + // Error states have proper role="alert" + fireEvent.error(screen.getByAltText('Evidence image 1')); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/lib/evidence-validation.ts b/src/lib/evidence-validation.ts new file mode 100644 index 00000000..b00473ac --- /dev/null +++ b/src/lib/evidence-validation.ts @@ -0,0 +1,125 @@ +/** + * Safe evidence media validation utilities + * Validates remote sources, dimensions, formats, and ensures safe loading behavior + */ + +// Allowed image MIME types +const ALLOWED_IMAGE_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', +]); + +// Allowed image file extensions +const ALLOWED_IMAGE_EXTENSIONS = new Set([ + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif' +]); + +// Allowed video MIME types +const ALLOWED_VIDEO_MIME_TYPES = new Set([ + 'video/mp4', + 'video/webm', +]); + +// Allowed video file extensions +const ALLOWED_VIDEO_EXTENSIONS = new Set(['.mp4', '.webm']); + +// Allowed document MIME types (only PDFs for safety) +const ALLOWED_DOCUMENT_MIME_TYPES = new Set(['application/pdf']); +const ALLOWED_DOCUMENT_EXTENSIONS = new Set(['.pdf']); + +// Blocked URL schemes to prevent XSS +const BLOCKED_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:']); + +// Validate that a URL is safe to use +export function isValidMediaUrl(url: string): boolean { + try { + // Check for blocked schemes first + const lowerUrl = url.toLowerCase(); + for (const scheme of BLOCKED_SCHEMES) { + if (lowerUrl.startsWith(scheme)) { + return false; + } + } + + // For remote URLs, only allow HTTPS + const urlObj = new URL(url); + if (urlObj.protocol !== 'https:' && !url.startsWith('/')) { // Allow relative paths (local assets) + return false; + } + + return true; + } catch { + // If URL parsing fails, it's an invalid URL + return false; + } +} + +// Get file extension from URL +function getFileExtension(url: string): string { + try { + const pathname = new URL(url, window.location.origin).pathname; + const lastDot = pathname.lastIndexOf('.'); + if (lastDot === -1) return ''; + return pathname.slice(lastDot).toLowerCase(); + } catch { + return ''; + } +} + +// Validate image URL and format +export function isValidImageUrl(url: string): boolean { + if (!isValidMediaUrl(url)) return false; + const ext = getFileExtension(url); + return ALLOWED_IMAGE_EXTENSIONS.has(ext); +} + +// Validate video URL and format +export function isValidVideoUrl(url: string): boolean { + if (!isValidMediaUrl(url)) return false; + const ext = getFileExtension(url); + return ALLOWED_VIDEO_EXTENSIONS.has(ext); +} + +// Validate document URL and format +export function isValidDocumentUrl(url: string): boolean { + if (!isValidMediaUrl(url)) return false; + const ext = getFileExtension(url); + return ALLOWED_DOCUMENT_EXTENSIONS.has(ext); +} + +// Validate link URL (for external links) +export function isValidLinkUrl(url: string): boolean { + if (!isValidMediaUrl(url)) return false; + try { + const urlObj = new URL(url); + // Only allow HTTPS for external links + return urlObj.protocol === 'https:'; + } catch { + // If it's a relative URL, that's fine too + return url.startsWith('/'); + } +} + +// Media loading state hook return type +export interface MediaLoadingState { + isLoading: boolean; + hasError: boolean; + isLoaded: boolean; +} + +// Create initial media state +export function createInitialMediaState(): MediaLoadingState { + return { + isLoading: true, + hasError: false, + isLoaded: false, + }; +} + +// Helper to check if evidence type can contain media +export function isMediaType(type: string): boolean { + return ['image', 'video'].includes(type); +} \ No newline at end of file