Files
duty-teller/tests/test_calendar_ics.py
Nikolay Tatarinov d5da265b5f
All checks were successful
CI / lint-and-test (push) Successful in 24s
feat: enhance HTTP handling and configuration
- Introduced a new utility function `safe_urlopen` to ensure only allowed URL schemes (http, https) are opened, enhancing security against path traversal vulnerabilities.
- Updated the `run.py` and `calendar_ics.py` files to utilize `safe_urlopen` for HTTP requests, improving error handling and security.
- Added `HTTP_HOST` configuration to the settings, allowing dynamic binding of the HTTP server host.
- Revised the `.env.example` file to include the new `HTTP_HOST` variable with a description.
- Enhanced tests for `safe_urlopen` to validate behavior with disallowed URL schemes and ensure proper integration in existing functionality.
2026-02-24 14:16:34 +03:00

174 lines
5.8 KiB
Python

"""Tests for duty_teller.api.calendar_ics (_to_date, _event_date_range, _get_events_from_ics, get_calendar_events)."""
from datetime import date, datetime
from unittest.mock import patch
import pytest
from icalendar import Calendar, Event
from duty_teller.api import calendar_ics as mod
class TestToDate:
"""Tests for _to_date."""
def test_datetime_returns_date(self):
dt = datetime(2025, 3, 15, 10, 0, 0)
assert mod._to_date(dt) == date(2025, 3, 15)
def test_date_returns_same(self):
d = date(2025, 3, 15)
assert mod._to_date(d) == date(2025, 3, 15)
def test_invalid_returns_none(self):
assert mod._to_date("2025-03-15") is None
assert mod._to_date(None) is None
assert mod._to_date(123) is None
class TestEventDateRange:
"""Tests for _event_date_range. DTEND is exclusive in iCalendar."""
def _vevent(self, dtstart_value, dtend_value=None):
"""Build a minimal VEVENT component using icalendar.Event."""
cal = Calendar()
ev = Event()
ev.add("dtstart", dtstart_value)
if dtend_value is not None:
ev.add("dtend", dtend_value)
cal.add_component(ev)
for c in cal.walk():
if c.name == "VEVENT":
return c
raise AssertionError("no vevent")
def test_dtstart_and_dtend_exclusive_end(self):
start = date(2025, 1, 10)
end_exclusive = date(2025, 1, 13) # last day of event = 2025-01-12
comp = self._vevent(start, end_exclusive)
s, e = mod._event_date_range(comp)
assert s == date(2025, 1, 10)
assert e == date(2025, 1, 12)
def test_only_dtstart_returns_same_for_start_and_end(self):
start = date(2025, 2, 1)
comp = self._vevent(start)
s, e = mod._event_date_range(comp)
assert s == date(2025, 2, 1)
assert e == date(2025, 2, 1)
def test_no_dtstart_returns_none_none(self):
cal = Calendar()
ev = Event()
ev.add("summary", "No dates")
cal.add_component(ev)
for c in cal.walk():
if c.name == "VEVENT":
s, e = mod._event_date_range(c)
assert s is None
assert e is None
return
pytest.fail("no vevent")
class TestGetEventsFromIcs:
"""Tests for _get_events_from_ics."""
def test_minimal_ics_one_event(self):
cal = Calendar()
ev = Event()
ev.add("dtstart", date(2025, 1, 15))
ev.add("summary", "Meeting")
cal.add_component(ev)
raw = cal.to_ical()
result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31")
assert len(result) == 1
assert result[0]["date"] == "2025-01-15"
assert result[0]["summary"] == "Meeting"
def test_event_filtered_by_date_range(self):
cal = Calendar()
ev = Event()
ev.add("dtstart", date(2025, 1, 10))
ev.add("dtend", date(2025, 1, 14)) # 10,11,12,13
ev.add("summary", "Multi")
cal.add_component(ev)
raw = cal.to_ical()
result = mod._get_events_from_ics(raw, "2025-01-12", "2025-01-13")
assert len(result) == 2
assert result[0]["date"] == "2025-01-12"
assert result[1]["date"] == "2025-01-13"
def test_empty_ics_returns_empty_list(self):
cal = Calendar()
raw = cal.to_ical()
result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31")
assert result == []
def test_broken_ics_returns_empty_list(self):
result = mod._get_events_from_ics(
b"not ical at all", "2025-01-01", "2025-01-31"
)
assert result == []
def test_recurring_events_skipped(self):
cal = Calendar()
ev = Event()
ev.add("dtstart", date(2025, 1, 15))
ev.add("rrule", {"freq": "daily", "count": 3})
ev.add("summary", "Recurring")
cal.add_component(ev)
raw = cal.to_ical()
result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31")
assert result == []
class TestGetCalendarEvents:
"""Tests for get_calendar_events (with mocked _fetch_ics, no real HTTP)."""
def test_empty_url_returns_empty(self):
assert mod.get_calendar_events("", "2025-01-01", "2025-01-31") == []
def test_disallowed_url_scheme_returns_empty(self):
"""get_calendar_events: file:// or ftp:// URL does not call urlopen, returns []."""
result = mod.get_calendar_events(
"file:///etc/passwd", "2025-01-01", "2025-01-31"
)
assert result == []
result = mod.get_calendar_events(
"ftp://example.com/cal.ics", "2025-01-01", "2025-01-31"
)
assert result == []
def test_from_after_to_returns_empty(self):
assert (
mod.get_calendar_events(
"https://example.com/a.ics", "2025-02-01", "2025-01-01"
)
== []
)
@patch.object(mod, "_fetch_ics", return_value=None)
def test_fetch_returns_none_returns_empty(self, mock_fetch):
result = mod.get_calendar_events(
"https://example.com/a.ics", "2025-01-01", "2025-01-31"
)
assert result == []
mock_fetch.assert_called_once()
@patch.object(mod, "_fetch_ics")
def test_success_returns_events(self, mock_fetch):
cal = Calendar()
ev = Event()
ev.add("dtstart", date(2025, 1, 20))
ev.add("summary", "Test event")
cal.add_component(ev)
mock_fetch.return_value = cal.to_ical()
result = mod.get_calendar_events(
"https://example.com/cal.ics", "2025-01-01", "2025-01-31"
)
assert len(result) == 1
assert result[0]["date"] == "2025-01-20"
assert result[0]["summary"] == "Test event"
mock_fetch.assert_called_once_with("https://example.com/cal.ics")