ff9a3ff49f
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
415 lines
17 KiB
Python
415 lines
17 KiB
Python
"""
|
|
Tests for URL-based file upload functionality
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestURLUploadValidation:
|
|
"""Test URL validation and SSRF protection"""
|
|
|
|
def test_validate_url_scheme_http_allowed(self):
|
|
"""Test that HTTP URLs are allowed"""
|
|
from app.api.url_upload import URLUploadRequest
|
|
|
|
request = URLUploadRequest(url="http://example.com/file.pdf")
|
|
assert str(request.url) == "http://example.com/file.pdf"
|
|
|
|
def test_validate_url_scheme_https_allowed(self):
|
|
"""Test that HTTPS URLs are allowed"""
|
|
from app.api.url_upload import URLUploadRequest
|
|
|
|
request = URLUploadRequest(url="https://example.com/file.pdf")
|
|
assert str(request.url) == "https://example.com/file.pdf"
|
|
|
|
def test_validate_url_scheme_ftp_rejected(self):
|
|
"""Test that FTP URLs are rejected"""
|
|
from pydantic import ValidationError
|
|
|
|
from app.api.url_upload import URLUploadRequest
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
URLUploadRequest(url="ftp://example.com/file.pdf")
|
|
# Pydantic HttpUrl validates scheme automatically
|
|
assert "url_scheme" in str(exc_info.value)
|
|
|
|
def test_validate_url_scheme_file_rejected(self):
|
|
"""Test that file:// URLs are rejected"""
|
|
from pydantic import ValidationError
|
|
|
|
from app.api.url_upload import URLUploadRequest
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
URLUploadRequest(url="file:///etc/passwd")
|
|
# Pydantic HttpUrl validates scheme automatically
|
|
assert "url_scheme" in str(exc_info.value)
|
|
|
|
def test_is_private_ip_localhost(self):
|
|
"""Test that localhost is detected as private"""
|
|
from app.api.url_upload import is_private_ip
|
|
|
|
assert is_private_ip("127.0.0.1") is True
|
|
assert is_private_ip("localhost") is True
|
|
|
|
def test_is_private_ip_private_ranges(self):
|
|
"""Test that private IP ranges are detected"""
|
|
from app.api.url_upload import is_private_ip
|
|
|
|
# Private IP ranges
|
|
assert is_private_ip("10.0.0.1") is True
|
|
assert is_private_ip("172.16.0.1") is True
|
|
assert is_private_ip("192.168.1.1") is True
|
|
assert is_private_ip("169.254.169.254") is True # AWS metadata
|
|
|
|
def test_is_private_ip_public_allowed(self):
|
|
"""Test that public IPs are allowed"""
|
|
from app.api.url_upload import is_private_ip
|
|
|
|
# Public IPs should not be blocked
|
|
assert is_private_ip("8.8.8.8") is False
|
|
assert is_private_ip("1.1.1.1") is False
|
|
|
|
def test_validate_url_safety_blocks_localhost(self, client):
|
|
"""Test that localhost URLs are blocked"""
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.url_upload import validate_url_safety
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
validate_url_safety("http://localhost/file.pdf")
|
|
assert exc_info.value.status_code == 400
|
|
assert "private/internal" in exc_info.value.detail
|
|
|
|
def test_validate_url_safety_blocks_private_ip(self, client):
|
|
"""Test that private IP URLs are blocked"""
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.url_upload import validate_url_safety
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
validate_url_safety("http://192.168.1.1/file.pdf")
|
|
assert exc_info.value.status_code == 400
|
|
assert "private/internal" in exc_info.value.detail
|
|
|
|
def test_validate_url_safety_blocks_metadata_endpoint(self):
|
|
"""Test that cloud metadata endpoints are blocked"""
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.url_upload import validate_url_safety
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
validate_url_safety("http://169.254.169.254/latest/meta-data/")
|
|
assert exc_info.value.status_code == 400
|
|
# Check for either metadata OR private/internal (169.254.x.x is link-local)
|
|
assert "metadata" in exc_info.value.detail or "private" in exc_info.value.detail
|
|
|
|
def test_validate_url_safety_allows_public_url(self):
|
|
"""Test that public URLs are allowed"""
|
|
from app.api.url_upload import validate_url_safety
|
|
|
|
# Should not raise
|
|
validate_url_safety("https://example.com/file.pdf")
|
|
|
|
def test_validate_file_type_pdf_allowed(self):
|
|
"""Test that PDF files are allowed"""
|
|
from app.api.url_upload import validate_file_type
|
|
|
|
assert validate_file_type("application/pdf", "file.pdf") is True
|
|
|
|
def test_validate_file_type_office_documents_allowed(self):
|
|
"""Test that Office documents are allowed"""
|
|
from app.api.url_upload import validate_file_type
|
|
|
|
# Word
|
|
assert validate_file_type("application/msword", "file.doc") is True
|
|
assert (
|
|
validate_file_type("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "file.docx")
|
|
is True
|
|
)
|
|
|
|
# Excel
|
|
assert validate_file_type("application/vnd.ms-excel", "file.xls") is True
|
|
assert (
|
|
validate_file_type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "file.xlsx") is True
|
|
)
|
|
|
|
def test_validate_file_type_images_allowed(self):
|
|
"""Test that image files are allowed"""
|
|
from app.api.url_upload import validate_file_type
|
|
|
|
assert validate_file_type("image/jpeg", "file.jpg") is True
|
|
assert validate_file_type("image/png", "file.png") is True
|
|
assert validate_file_type("image/gif", "file.gif") is True
|
|
|
|
def test_validate_file_type_executable_rejected(self):
|
|
"""Test that executable files are rejected"""
|
|
from app.api.url_upload import validate_file_type
|
|
|
|
assert validate_file_type("application/x-executable", "file.exe") is False
|
|
assert validate_file_type("application/x-sh", "file.sh") is False
|
|
|
|
def test_validate_file_type_with_charset(self):
|
|
"""Test content-type with charset parameter"""
|
|
from app.api.url_upload import validate_file_type
|
|
|
|
# Content-Type often includes charset
|
|
assert validate_file_type("application/pdf; charset=utf-8", "file.pdf") is True
|
|
assert validate_file_type("text/plain; charset=utf-8", "file.txt") is True
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestURLUploadEndpoint:
|
|
"""Integration tests for URL upload endpoint"""
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
@patch("app.api.url_upload.process_document")
|
|
def test_process_url_requires_authentication(self, mock_process_document, mock_requests_get, client, monkeypatch):
|
|
"""Test that endpoint requires authentication when auth is enabled"""
|
|
# Mock successful download to prevent actual HTTP requests
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
|
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
|
mock_response.raise_for_status = Mock()
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
# Mock Celery task
|
|
mock_task = Mock()
|
|
mock_task.id = "test-task-id"
|
|
mock_process_document.delay.return_value = mock_task
|
|
|
|
# Temporarily enable auth for this test
|
|
monkeypatch.setenv("AUTH_ENABLED", "True")
|
|
|
|
# Since we can't easily reload the app config, we'll just test that the endpoint exists
|
|
# In production with auth enabled, it would redirect or return 401
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
|
# With AUTH_ENABLED=False (default in tests), this should work but fail for other reasons
|
|
# (like no mocking). We're just checking the endpoint exists and is reachable.
|
|
assert response.status_code != 404 # Endpoint should exist
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
@patch("app.api.url_upload.process_document")
|
|
def test_process_url_success(self, mock_process_document, mock_requests_get, client, tmp_path):
|
|
"""Test successful URL processing"""
|
|
# Mock successful download
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
|
mock_response.iter_content = Mock(return_value=[b"PDF content here"])
|
|
mock_response.raise_for_status = Mock()
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
# Mock Celery task
|
|
mock_task = Mock()
|
|
mock_task.id = "test-task-id-123"
|
|
mock_process_document.delay.return_value = mock_task
|
|
|
|
# Make request
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/document.pdf"})
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["task_id"] == "test-task-id-123"
|
|
assert data["status"] == "queued"
|
|
assert "filename" in data
|
|
assert "size" in data
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
def test_process_url_blocks_private_ip(self, mock_requests_get, client):
|
|
"""Test that private IPs are blocked"""
|
|
response = client.post("/api/process-url", json={"url": "http://192.168.1.1/file.pdf"})
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "private/internal" in data["detail"]
|
|
|
|
# Should not make HTTP request
|
|
mock_requests_get.assert_not_called()
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
def test_process_url_blocks_localhost(self, mock_requests_get, client):
|
|
"""Test that localhost is blocked"""
|
|
response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"})
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "private/internal" in data["detail"]
|
|
|
|
# Should not make HTTP request
|
|
mock_requests_get.assert_not_called()
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
def test_process_url_blocks_metadata_endpoint(self, mock_requests_get, client):
|
|
"""Test that cloud metadata endpoints are blocked"""
|
|
response = client.post("/api/process-url", json={"url": "http://169.254.169.254/latest/meta-data/"})
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
# 169.254.x.x is also a link-local (private) IP, so either error message is acceptable
|
|
assert "metadata" in data["detail"] or "private" in data["detail"]
|
|
|
|
# Should not make HTTP request
|
|
mock_requests_get.assert_not_called()
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
def test_process_url_invalid_file_type(self, mock_requests_get, client):
|
|
"""Test that invalid file types are rejected"""
|
|
# Mock response with executable content-type
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "application/x-executable"}
|
|
mock_response.raise_for_status = Mock()
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/malware.exe"})
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "Unsupported file type" in data["detail"]
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
@patch("app.api.url_upload.process_document")
|
|
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_requests_get, client):
|
|
"""Test that files too large are rejected based on Content-Length header"""
|
|
from app.config import settings
|
|
|
|
# Mock response with large content-length
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {
|
|
"Content-Type": "application/pdf",
|
|
"Content-Length": str(settings.max_upload_size + 1000),
|
|
}
|
|
mock_response.raise_for_status = Mock()
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/huge.pdf"})
|
|
|
|
assert response.status_code == 413
|
|
data = response.json()
|
|
assert "too large" in data["detail"]
|
|
|
|
# Should not process document
|
|
mock_process_document.delay.assert_not_called()
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
def test_process_url_timeout_error(self, mock_requests_get, client):
|
|
"""Test handling of timeout errors"""
|
|
mock_requests_get.side_effect = requests.exceptions.Timeout("Request timed out")
|
|
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/slow.pdf"})
|
|
|
|
assert response.status_code == 408
|
|
data = response.json()
|
|
assert "timeout" in data["detail"].lower()
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
def test_process_url_connection_error(self, mock_requests_get, client):
|
|
"""Test handling of connection errors"""
|
|
mock_requests_get.side_effect = requests.exceptions.ConnectionError("Failed to connect")
|
|
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
|
|
|
assert response.status_code == 502
|
|
data = response.json()
|
|
assert "connect" in data["detail"].lower()
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
def test_process_url_http_error_404(self, mock_requests_get, client):
|
|
"""Test handling of HTTP 404 errors"""
|
|
mock_response = Mock()
|
|
mock_response.status_code = 404
|
|
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
|
"404 Not Found", response=mock_response
|
|
)
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"})
|
|
|
|
assert response.status_code == 404
|
|
data = response.json()
|
|
assert "HTTP error" in data["detail"]
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
@patch("app.api.url_upload.process_document")
|
|
def test_process_url_with_custom_filename(self, mock_process_document, mock_requests_get, client, tmp_path):
|
|
"""Test URL upload with custom filename"""
|
|
# Mock successful download
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
|
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
|
mock_response.raise_for_status = Mock()
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
# Mock Celery task
|
|
mock_task = Mock()
|
|
mock_task.id = "test-task-id"
|
|
mock_process_document.delay.return_value = mock_task
|
|
|
|
# Make request with custom filename
|
|
response = client.post(
|
|
"/api/process-url", json={"url": "https://example.com/doc.pdf", "filename": "my-document.pdf"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["filename"] == "my-document.pdf"
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
@patch("app.api.url_upload.process_document")
|
|
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_requests_get, client, tmp_path):
|
|
"""Test that filename is extracted from URL when not provided"""
|
|
# Mock successful download
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
|
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
|
mock_response.raise_for_status = Mock()
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
# Mock Celery task
|
|
mock_task = Mock()
|
|
mock_task.id = "test-task-id"
|
|
mock_process_document.delay.return_value = mock_task
|
|
|
|
# Make request without custom filename
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/annual-report.pdf"})
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# Should extract "annual-report.pdf" from URL
|
|
assert "annual-report" in data["filename"]
|
|
|
|
@patch("app.api.url_upload.requests.get")
|
|
@patch("app.api.url_upload.process_document")
|
|
def test_process_url_file_size_during_download(self, mock_process_document, mock_requests_get, client):
|
|
"""Test that file size is checked during download"""
|
|
from app.config import settings
|
|
|
|
# Create a large chunk that exceeds max_upload_size
|
|
large_chunk = b"x" * (settings.max_upload_size + 1000)
|
|
|
|
# Mock response without Content-Length header
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "application/pdf"} # No Content-Length
|
|
mock_response.iter_content = Mock(return_value=[large_chunk])
|
|
mock_response.raise_for_status = Mock()
|
|
mock_requests_get.return_value = mock_response
|
|
|
|
response = client.post("/api/process-url", json={"url": "https://example.com/big.pdf"})
|
|
|
|
assert response.status_code == 413
|
|
data = response.json()
|
|
assert "too large" in data["detail"]
|
|
|
|
# Should not process document
|
|
mock_process_document.delay.assert_not_called()
|