feat: enhance error handling and configuration validation
Some checks failed
CI / lint-and-test (push) Failing after 27s
Some checks failed
CI / lint-and-test (push) Failing after 27s
- Added a global exception handler to log unhandled exceptions and return a generic 500 JSON response without exposing details to the client. - Updated the configuration to validate the `DATABASE_URL` format, ensuring it starts with `sqlite://` or `postgresql://`, and log warnings for invalid formats. - Introduced safe parsing for numeric environment variables (`HTTP_PORT`, `INIT_DATA_MAX_AGE_SECONDS`) with defaults on invalid values, including logging warnings for out-of-range values. - Enhanced the duty schedule parser to enforce limits on the number of schedule rows and the length of full names and duty strings, raising appropriate errors when exceeded. - Updated internationalization messages to include generic error responses for import failures and parsing issues, improving user experience. - Added unit tests to verify the new error handling and configuration validation behaviors.
This commit is contained in:
@@ -23,6 +23,21 @@ def test_health(client):
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_unhandled_exception_returns_500_json(client):
|
||||
"""Global exception handler returns 500 JSON without leaking exception details."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from duty_teller.api.app import global_exception_handler
|
||||
|
||||
# Call the registered handler directly: it returns JSON and does not expose str(exc).
|
||||
request = MagicMock()
|
||||
exc = RuntimeError("internal failure")
|
||||
response = global_exception_handler(request, exc)
|
||||
assert response.status_code == 500
|
||||
assert response.body.decode() == '{"detail":"Internal server error"}'
|
||||
assert "internal failure" not in response.body.decode()
|
||||
|
||||
|
||||
def test_health_has_vary_accept_language(client):
|
||||
"""NoCacheStaticMiddleware adds Vary: Accept-Language to all responses."""
|
||||
r = client.get("/health")
|
||||
@@ -60,6 +75,20 @@ def test_app_config_js_returns_lang_from_default_language(client):
|
||||
assert config.DEFAULT_LANGUAGE in body
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.DEFAULT_LANGUAGE", '"; alert(1); "')
|
||||
@patch("duty_teller.api.app.config.LOG_LEVEL_STR", "DEBUG\x00INJECT")
|
||||
def test_app_config_js_sanitizes_lang_and_log_level(client):
|
||||
"""config.js uses whitelist: invalid lang/log_level produce safe defaults, no script injection."""
|
||||
r = client.get("/app/config.js")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# Must be valid JS and not contain the raw malicious strings.
|
||||
assert 'window.__DT_LANG = "en"' in body or 'window.__DT_LANG = "ru"' in body
|
||||
assert "alert" not in body
|
||||
assert "INJECT" not in body
|
||||
assert "window.__DT_LOG_LEVEL" in body
|
||||
|
||||
|
||||
def test_duties_invalid_date_format(client):
|
||||
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
||||
assert r.status_code == 400
|
||||
@@ -74,6 +103,30 @@ def test_duties_from_after_to(client):
|
||||
assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_duties_range_too_large_400(client):
|
||||
"""Date range longer than MAX_DATE_RANGE_DAYS returns 400 with dates.range_too_large message."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
|
||||
|
||||
from_d = date(2020, 1, 1)
|
||||
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
|
||||
r = client.get(
|
||||
"/api/duties",
|
||||
params={"from": from_d.isoformat(), "to": to_d.isoformat()},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
detail = r.json()["detail"]
|
||||
# EN: "Date range is too large. Request a shorter period." / RU: "Диапазон дат слишком большой..."
|
||||
assert (
|
||||
"range" in detail.lower()
|
||||
or "short" in detail.lower()
|
||||
or "короткий" in detail
|
||||
or "большой" in detail
|
||||
)
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_duties_403_without_init_data(client):
|
||||
"""Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403 (any client)."""
|
||||
@@ -309,20 +362,21 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
|
||||
|
||||
def test_calendar_ical_team_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 without DB."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/team/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.headers.get("content-type", "").startswith("application/json")
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
valid_format_token = "B" * 43
|
||||
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
@@ -374,11 +428,10 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
|
||||
|
||||
def test_calendar_ical_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call."""
|
||||
# Token format must be base64url, 40–50 chars; short or invalid chars → 404
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics")
|
||||
assert r2.status_code == 404
|
||||
r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics")
|
||||
@@ -387,13 +440,12 @@ def test_calendar_ical_404_invalid_token_format(client):
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
# Use a token that passes format validation (base64url, 40–50 chars)
|
||||
valid_format_token = "A" * 43
|
||||
r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user