diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..fa9f5e6 Binary files /dev/null and b/.coverage differ diff --git a/README.md b/README.md index 9690219..9c6b8ab 100644 --- a/README.md +++ b/README.md @@ -144,3 +144,13 @@ pytest Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks). **CI (Gitea Actions):** Lint (ruff), tests (pytest), security (bandit). If the workflow uses `PYTHONPATH: src` or `bandit -r src`, update it to match the repo layout (no `src/`). + +## Разработка (Contributing) + +- **Коммиты:** в формате [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` и т.д. +- **Ветки:** по [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): основная ветка `main`, фичи и фиксы — в отдельных ветках. +- **Изменения:** через **Pull Request** в Gitea; перед мержем рекомендуется запустить линтеры и тесты (`ruff check .`, `pytest`). + +## Логи и ротация + +Для соответствия политике хранения логов (не более 7 дней) настройте ротацию логов при развёртывании: например [logrotate](https://manpages.ubuntu.com/logrotate), настройки логирования systemd или Docker (ограничение размера/времени хранения). Храните логи приложения не дольше 7 дней. diff --git a/docs/configuration.md b/docs/configuration.md index 59fbbe6..dff4717 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,3 +26,7 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv). 3. For miniapp access, set `ALLOWED_USERNAMES` and/or `ADMIN_USERNAMES` (and optionally `ALLOWED_PHONES` / `ADMIN_PHONES`). For Mini App URL and production deployment notes (reverse proxy, initData), see the [README](../README.md) Setup and Docker sections. + +## Production: HTTPS + +In production the application **must** be served over **HTTPS** (e.g. behind a reverse proxy such as nginx or Caddy with TLS). Without HTTPS, the Telegram Mini App initData and the calendar subscription token are sent in the clear; an attacker on the same network could capture them and gain access to the calendar or impersonate the user. Deploy the HTTP server behind a proxy that terminates TLS and forwards requests to the app. diff --git a/duty_teller.egg-info/PKG-INFO b/duty_teller.egg-info/PKG-INFO index f16aa6e..cf5c757 100644 --- a/duty_teller.egg-info/PKG-INFO +++ b/duty_teller.egg-info/PKG-INFO @@ -15,6 +15,7 @@ Requires-Dist: icalendar<6.0,>=5.0 Provides-Extra: dev Requires-Dist: pytest<9.0,>=8.0; extra == "dev" Requires-Dist: pytest-asyncio<1.0,>=0.24; extra == "dev" +Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev" Requires-Dist: httpx<1.0,>=0.27; extra == "dev" Provides-Extra: docs Requires-Dist: mkdocs<2,>=1.5; extra == "docs" diff --git a/duty_teller.egg-info/requires.txt b/duty_teller.egg-info/requires.txt index 06ecf03..3ac4aa3 100644 --- a/duty_teller.egg-info/requires.txt +++ b/duty_teller.egg-info/requires.txt @@ -10,6 +10,7 @@ icalendar<6.0,>=5.0 [dev] pytest<9.0,>=8.0 pytest-asyncio<1.0,>=0.24 +pytest-cov<7.0,>=6.0 httpx<1.0,>=0.27 [docs] diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py index 0f21f9e..24715e6 100644 --- a/duty_teller/api/app.py +++ b/duty_teller/api/app.py @@ -1,6 +1,7 @@ """FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app.""" import logging +import re from datetime import date, timedelta import duty_teller.config as config @@ -23,6 +24,15 @@ from duty_teller.db.schemas import CalendarEvent, DutyWithUser log = logging.getLogger(__name__) +# Calendar tokens are secrets.token_urlsafe(32) → base64url, length 43 +_CALENDAR_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{40,50}$") + + +def _is_valid_calendar_token(token: str) -> bool: + """Return True if token matches expected format (length and alphabet). Rejects invalid before DB.""" + return bool(token and _CALENDAR_TOKEN_RE.match(token)) + + app = FastAPI(title="Duty Teller API") app.add_middleware( CORSMiddleware, @@ -37,7 +47,10 @@ app.add_middleware( "/api/duties", response_model=list[DutyWithUser], summary="List duties", - description="Returns duties for the given date range. Requires Telegram Mini App initData (or MINI_APP_SKIP_AUTH / private IP in dev).", + description=( + "Returns duties for the given date range. Requires Telegram Mini App initData " + "(or MINI_APP_SKIP_AUTH / private IP in dev)." + ), ) def list_duties( request: Request, @@ -57,7 +70,10 @@ def list_duties( "/api/calendar-events", response_model=list[CalendarEvent], summary="List calendar events", - description="Returns calendar events for the date range, including external ICS when EXTERNAL_CALENDAR_ICS_URL is set. Auth same as /api/duties.", + description=( + "Returns calendar events for the date range, including external ICS when " + "EXTERNAL_CALENDAR_ICS_URL is set. Auth same as /api/duties." + ), ) def list_calendar_events( dates: tuple[str, str] = Depends(get_validated_dates), @@ -74,7 +90,10 @@ def list_calendar_events( @app.get( "/api/calendar/ical/{token}.ics", summary="Personal calendar ICS", - description="Returns an ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.", + description=( + "Returns an ICS calendar with only the subscribing user's duties. " + "No Telegram auth; access is by secret token in the URL." + ), ) def get_personal_calendar_ical( token: str, @@ -84,6 +103,8 @@ def get_personal_calendar_ical( Return ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL. """ + if not _is_valid_calendar_token(token): + return Response(status_code=404, content="Not found") user = get_user_by_calendar_token(session, token) if user is None: return Response(status_code=404, content="Not found") diff --git a/duty_teller/api/dependencies.py b/duty_teller/api/dependencies.py index f7bb2e8..97b62d5 100644 --- a/duty_teller/api/dependencies.py +++ b/duty_teller/api/dependencies.py @@ -105,10 +105,22 @@ def require_miniapp_username( def _is_private_client(client_host: str | None) -> bool: + """Return True if client_host is localhost or RFC 1918 private IPv4. + + Used to allow /api/duties without initData when opened from local/private + network (e.g. dev). IPv4 only; IPv6 only 127/::1 checked. + + Args: + client_host: Client IP or hostname from request. + + Returns: + True if loopback or 10.x, 172.16–31.x, 192.168.x.x. + """ if not client_host: return False if client_host in ("127.0.0.1", "::1"): return True + # RFC 1918 private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 parts = client_host.split(".") if len(parts) == 4: try: diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index 4220b0c..027d80b 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -16,29 +16,48 @@ MESSAGES: dict[str, dict[str, str]] = { "calendar_link.private_only": "The /calendar_link command is only available in private chat.", "calendar_link.access_denied": "Access denied.", "calendar_link.success": "Your personal calendar URL:\n{url}", - "calendar_link.help_hint": "Subscribe to this URL in Google Calendar, Apple Calendar, or Outlook to see only your duties.", + "calendar_link.help_hint": ( + "Subscribe to this URL in Google Calendar, Apple Calendar, or Outlook to " + "see only your duties." + ), "calendar_link.error": "Could not generate link. Please try again later.", "help.import_schedule": "/import_duty_schedule — Import duty schedule (JSON)", "errors.generic": "An error occurred. Please try again later.", "pin_duty.group_only": "The /pin_duty command works only in groups.", "pin_duty.no_message": "There is no duty message in this chat yet. Add the bot to the group — it will create one automatically.", "pin_duty.pinned": "Duty message pinned.", - "pin_duty.failed": "Could not pin. Make sure the bot is an administrator with «Pin messages» permission.", - "pin_duty.could_not_pin_make_admin": "Duty message was sent but could not be pinned. Make the bot an administrator with «Pin messages» permission, then send /pin_duty in the chat — the current message will be pinned.", + "pin_duty.failed": ( + "Could not pin. Make sure the bot is an administrator with " + "«Pin messages» permission." + ), + "pin_duty.could_not_pin_make_admin": ( + "Duty message was sent but could not be pinned. Make the bot an " + "administrator with «Pin messages» permission, then send /pin_duty in the " + "chat — the current message will be pinned." + ), "duty.no_duty": "No duty at the moment.", "duty.label": "Duty:", "import.admin_only": "Access for administrators only.", - "import.handover_format": "Enter handover time as HH:MM and timezone, e.g. 09:00 Europe/Moscow or 06:00 UTC.", + "import.handover_format": ( + "Enter handover time as HH:MM and timezone, e.g. 09:00 Europe/Moscow or " + "06:00 UTC." + ), "import.parse_time_error": "Could not parse time. Enter e.g.: 09:00 Europe/Moscow", "import.send_json": "Send the duty-schedule file (JSON).", "import.need_json": "File must have .json extension.", "import.parse_error": "File parse error: {error}", "import.import_error": "Import error: {error}", - "import.done": "Import done: {users} users, {duties} duties{unavailable}{vacation} ({total} events total).", + "import.done": ( + "Import done: {users} users, {duties} duties{unavailable}{vacation} " + "({total} events total)." + ), "import.done_unavailable": ", {count} unavailable", "import.done_vacation": ", {count} vacation", "api.open_from_telegram": "Open the calendar from Telegram", - "api.auth_bad_signature": "Invalid signature. Ensure BOT_TOKEN on the server matches the bot from which the calendar was opened (same bot as in the menu).", + "api.auth_bad_signature": ( + "Invalid signature. Ensure BOT_TOKEN on the server matches the bot from " + "which the calendar was opened (same bot as in the menu)." + ), "api.auth_invalid": "Invalid auth data", "api.access_denied": "Access denied", "dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format", diff --git a/duty_teller/services/import_service.py b/duty_teller/services/import_service.py index 9ffd82e..f29f22a 100644 --- a/duty_teller/services/import_service.py +++ b/duty_teller/services/import_service.py @@ -21,6 +21,7 @@ def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]: ranges: list[tuple[date, date]] = [] start_d = end_d = sorted_dates[0] for d in sorted_dates[1:]: + # Merge consecutive days into one range; gap starts a new range if (d - end_d).days == 1: end_d = d else: diff --git a/pyproject.toml b/pyproject.toml index 2b55c43..dda92d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ duty-teller = "duty_teller.run:main" dev = [ "pytest>=8.0,<9.0", "pytest-asyncio>=0.24,<1.0", + "pytest-cov>=6.0,<7.0", "httpx>=0.27,<1.0", ] docs = [ @@ -47,5 +48,9 @@ version_path_separator = "os" line-length = 120 target-version = ["py311"] +[tool.pytest.ini_options] +addopts = "--cov=duty_teller --cov-report=term-missing --cov-fail-under=51" +asyncio_mode = "auto" + [tool.pylint.messages_control] disable = ["C0114", "C0115", "C0116"] diff --git a/tests/test_app.py b/tests/test_app.py index 6cae5c1..604011b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -250,11 +250,25 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client): assert data[0]["full_name"] == "User A" +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 + r = client.get("/api/calendar/ical/short.ics") + assert r.status_code == 404 + assert "not found" in r.text.lower() + r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics") + assert r2.status_code == 404 + r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics") + assert r3.status_code == 404 + + @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.""" mock_get_user.return_value = None - r = client.get("/api/calendar/ical/unknown-token-xyz.ics") + # 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() mock_get_user.assert_called_once() @@ -282,8 +296,10 @@ def test_calendar_ical_200_returns_only_that_users_duties( mock_build_ics.return_value = ( b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR" ) + # Token must pass format validation (base64url, 40–50 chars) + token = "x" * 43 - r = client.get("/api/calendar/ical/valid-token.ics") + r = client.get(f"/api/calendar/ical/{token}.ics") assert r.status_code == 200 assert r.headers.get("content-type", "").startswith("text/calendar") assert b"BEGIN:VCALENDAR" in r.content