Files
gh-christianlouis-docuelevate/tests/test_input_validation.py
T
2026-02-22 16:02:00 +00:00

278 lines
10 KiB
Python

"""
Unit and integration tests for app/utils/input_validation.py.
Covers all validators: sort field/order, search query, task ID, and setting key.
"""
import pytest
from fastapi import HTTPException
# ---------------------------------------------------------------------------
# validate_sort_field
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateSortField:
"""Tests for validate_sort_field."""
def test_accepts_valid_sort_fields(self):
"""All declared sort fields should be accepted."""
from app.utils.input_validation import ALLOWED_SORT_FIELDS, validate_sort_field
for field in ALLOWED_SORT_FIELDS:
assert validate_sort_field(field) == field
def test_rejects_unknown_field(self):
"""An unknown sort field should raise 422."""
from app.utils.input_validation import validate_sort_field
with pytest.raises(HTTPException) as exc_info:
validate_sort_field("nonexistent_field")
assert exc_info.value.status_code == 422
def test_rejects_sql_injection_attempt(self):
"""A SQL-injection-style field name should raise 422."""
from app.utils.input_validation import validate_sort_field
with pytest.raises(HTTPException) as exc_info:
validate_sort_field("id; DROP TABLE files; --")
assert exc_info.value.status_code == 422
def test_rejects_empty_string(self):
"""An empty string should raise 422."""
from app.utils.input_validation import validate_sort_field
with pytest.raises(HTTPException) as exc_info:
validate_sort_field("")
assert exc_info.value.status_code == 422
def test_error_message_lists_allowed_values(self):
"""Error detail should mention the allowed values."""
from app.utils.input_validation import validate_sort_field
with pytest.raises(HTTPException) as exc_info:
validate_sort_field("bad_field")
assert "created_at" in exc_info.value.detail
# ---------------------------------------------------------------------------
# validate_sort_order
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateSortOrder:
"""Tests for validate_sort_order."""
def test_accepts_asc(self):
from app.utils.input_validation import validate_sort_order
assert validate_sort_order("asc") == "asc"
def test_accepts_desc(self):
from app.utils.input_validation import validate_sort_order
assert validate_sort_order("desc") == "desc"
def test_rejects_uppercase_asc(self):
"""Validation is case-sensitive; 'ASC' should be rejected."""
from app.utils.input_validation import validate_sort_order
with pytest.raises(HTTPException) as exc_info:
validate_sort_order("ASC")
assert exc_info.value.status_code == 422
def test_rejects_arbitrary_string(self):
from app.utils.input_validation import validate_sort_order
with pytest.raises(HTTPException) as exc_info:
validate_sort_order("random")
assert exc_info.value.status_code == 422
def test_rejects_empty_string(self):
from app.utils.input_validation import validate_sort_order
with pytest.raises(HTTPException) as exc_info:
validate_sort_order("")
assert exc_info.value.status_code == 422
# ---------------------------------------------------------------------------
# validate_search_query
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateSearchQuery:
"""Tests for validate_search_query."""
def test_returns_none_for_none_input(self):
from app.utils.input_validation import validate_search_query
assert validate_search_query(None) is None
def test_returns_none_for_blank_string(self):
from app.utils.input_validation import validate_search_query
assert validate_search_query(" ") is None
def test_strips_whitespace(self):
from app.utils.input_validation import validate_search_query
assert validate_search_query(" hello ") == "hello"
def test_accepts_normal_query(self):
from app.utils.input_validation import validate_search_query
assert validate_search_query("invoice 2024") == "invoice 2024"
def test_rejects_too_long_query(self):
"""A query longer than 255 characters should raise 422."""
from app.utils.input_validation import MAX_SEARCH_QUERY_LENGTH, validate_search_query
long_query = "a" * (MAX_SEARCH_QUERY_LENGTH + 1)
with pytest.raises(HTTPException) as exc_info:
validate_search_query(long_query)
assert exc_info.value.status_code == 422
def test_accepts_query_at_max_length(self):
"""A query exactly at the maximum length should be accepted."""
from app.utils.input_validation import MAX_SEARCH_QUERY_LENGTH, validate_search_query
exact_query = "a" * MAX_SEARCH_QUERY_LENGTH
assert validate_search_query(exact_query) == exact_query
# ---------------------------------------------------------------------------
# validate_task_id
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateTaskId:
"""Tests for validate_task_id."""
def test_accepts_valid_uuid(self):
from app.utils.input_validation import validate_task_id
valid_uuid = "550e8400-e29b-41d4-a716-446655440000"
assert validate_task_id(valid_uuid) == valid_uuid
def test_accepts_uppercase_uuid(self):
"""UUID validation should be case-insensitive."""
from app.utils.input_validation import validate_task_id
upper_uuid = "550E8400-E29B-41D4-A716-446655440000"
assert validate_task_id(upper_uuid) == upper_uuid
def test_rejects_non_v4_uuid(self):
"""A syntactically valid UUID with a version other than 4 should be rejected."""
from app.utils.input_validation import validate_task_id
# Version 1 UUID (version digit is '1', not '4')
with pytest.raises(HTTPException) as exc_info:
validate_task_id("550e8400-e29b-11d4-a716-446655440000")
assert exc_info.value.status_code == 422
def test_rejects_short_string(self):
from app.utils.input_validation import validate_task_id
with pytest.raises(HTTPException) as exc_info:
validate_task_id("abc-123")
assert exc_info.value.status_code == 422
def test_rejects_sql_injection(self):
from app.utils.input_validation import validate_task_id
with pytest.raises(HTTPException) as exc_info:
validate_task_id("'; DROP TABLE processing_logs; --")
assert exc_info.value.status_code == 422
def test_rejects_path_traversal(self):
from app.utils.input_validation import validate_task_id
with pytest.raises(HTTPException) as exc_info:
validate_task_id("../../etc/passwd")
assert exc_info.value.status_code == 422
def test_rejects_empty_string(self):
from app.utils.input_validation import validate_task_id
with pytest.raises(HTTPException) as exc_info:
validate_task_id("")
assert exc_info.value.status_code == 422
def test_error_message_mentions_uuid(self):
from app.utils.input_validation import validate_task_id
with pytest.raises(HTTPException) as exc_info:
validate_task_id("not-a-uuid")
assert "UUID" in exc_info.value.detail
# ---------------------------------------------------------------------------
# validate_setting_key
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateSettingKey:
"""Tests for validate_setting_key."""
def test_accepts_known_setting_key(self):
"""A key that exists in SETTING_METADATA should be accepted."""
from app.utils.input_validation import validate_setting_key
# 'workdir' is always defined in SETTING_METADATA
assert validate_setting_key("workdir") == "workdir"
def test_rejects_unknown_key_with_404(self):
"""An unknown but syntactically valid key should raise 404."""
from app.utils.input_validation import validate_setting_key
with pytest.raises(HTTPException) as exc_info:
validate_setting_key("totally_unknown_key_xyz")
assert exc_info.value.status_code == 404
def test_rejects_key_with_special_characters(self):
"""A key with special characters (e.g., injection attempt) should raise 400."""
from app.utils.input_validation import validate_setting_key
with pytest.raises(HTTPException) as exc_info:
validate_setting_key("__class__")
assert exc_info.value.status_code == 400
def test_rejects_key_with_dot_notation(self):
"""Dot-separated attribute traversal should be rejected."""
from app.utils.input_validation import validate_setting_key
with pytest.raises(HTTPException) as exc_info:
validate_setting_key("model_fields")
# model_fields is a Pydantic internal but not in SETTING_METADATA -> 404
assert exc_info.value.status_code == 404
def test_rejects_dunder_attributes(self):
"""Double-underscore attributes should be rejected (bad format)."""
from app.utils.input_validation import validate_setting_key
with pytest.raises(HTTPException) as exc_info:
validate_setting_key("__dict__")
assert exc_info.value.status_code == 400
def test_rejects_empty_key(self):
from app.utils.input_validation import validate_setting_key
with pytest.raises(HTTPException) as exc_info:
validate_setting_key("")
assert exc_info.value.status_code == 400
def test_rejects_key_starting_with_digit(self):
"""Keys starting with a digit should be rejected."""
from app.utils.input_validation import validate_setting_key
with pytest.raises(HTTPException) as exc_info:
validate_setting_key("1nvalid_key")
assert exc_info.value.status_code == 400