8f1abf8cdd
- Auto-formatted code with ruff format - Applied ruff linting fixes with --fix Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
277 lines
10 KiB
Python
277 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
|