chore: add coverage reporting and improve documentation
All checks were successful
CI / lint-and-test (push) Successful in 19s
All checks were successful
CI / lint-and-test (push) Successful in 19s
- Added `pytest-cov` as a development dependency for coverage reporting. - Configured pytest to include coverage options, ensuring code coverage is reported and enforced. - Updated the README to include contributing guidelines and logging policies, enhancing clarity for developers. - Added a new section in the configuration documentation emphasizing the necessity of serving the application over HTTPS in production for security purposes. - Introduced a new `.coverage` file to track test coverage metrics.
This commit is contained in:
10
README.md
10
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).
|
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/`).
|
**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 дней.
|
||||||
|
|||||||
@@ -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`).
|
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.
|
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.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Requires-Dist: icalendar<6.0,>=5.0
|
|||||||
Provides-Extra: dev
|
Provides-Extra: dev
|
||||||
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
||||||
Requires-Dist: pytest-asyncio<1.0,>=0.24; 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"
|
Requires-Dist: httpx<1.0,>=0.27; extra == "dev"
|
||||||
Provides-Extra: docs
|
Provides-Extra: docs
|
||||||
Requires-Dist: mkdocs<2,>=1.5; extra == "docs"
|
Requires-Dist: mkdocs<2,>=1.5; extra == "docs"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ icalendar<6.0,>=5.0
|
|||||||
[dev]
|
[dev]
|
||||||
pytest<9.0,>=8.0
|
pytest<9.0,>=8.0
|
||||||
pytest-asyncio<1.0,>=0.24
|
pytest-asyncio<1.0,>=0.24
|
||||||
|
pytest-cov<7.0,>=6.0
|
||||||
httpx<1.0,>=0.27
|
httpx<1.0,>=0.27
|
||||||
|
|
||||||
[docs]
|
[docs]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app."""
|
"""FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
@@ -23,6 +24,15 @@ from duty_teller.db.schemas import CalendarEvent, DutyWithUser
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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 = FastAPI(title="Duty Teller API")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -37,7 +47,10 @@ app.add_middleware(
|
|||||||
"/api/duties",
|
"/api/duties",
|
||||||
response_model=list[DutyWithUser],
|
response_model=list[DutyWithUser],
|
||||||
summary="List duties",
|
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(
|
def list_duties(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -57,7 +70,10 @@ def list_duties(
|
|||||||
"/api/calendar-events",
|
"/api/calendar-events",
|
||||||
response_model=list[CalendarEvent],
|
response_model=list[CalendarEvent],
|
||||||
summary="List calendar events",
|
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(
|
def list_calendar_events(
|
||||||
dates: tuple[str, str] = Depends(get_validated_dates),
|
dates: tuple[str, str] = Depends(get_validated_dates),
|
||||||
@@ -74,7 +90,10 @@ def list_calendar_events(
|
|||||||
@app.get(
|
@app.get(
|
||||||
"/api/calendar/ical/{token}.ics",
|
"/api/calendar/ical/{token}.ics",
|
||||||
summary="Personal calendar 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(
|
def get_personal_calendar_ical(
|
||||||
token: str,
|
token: str,
|
||||||
@@ -84,6 +103,8 @@ def get_personal_calendar_ical(
|
|||||||
Return ICS calendar with only the subscribing user's duties.
|
Return ICS calendar with only the subscribing user's duties.
|
||||||
No Telegram auth; access is by secret token in the URL.
|
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)
|
user = get_user_by_calendar_token(session, token)
|
||||||
if user is None:
|
if user is None:
|
||||||
return Response(status_code=404, content="Not found")
|
return Response(status_code=404, content="Not found")
|
||||||
|
|||||||
@@ -105,10 +105,22 @@ def require_miniapp_username(
|
|||||||
|
|
||||||
|
|
||||||
def _is_private_client(client_host: str | None) -> bool:
|
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:
|
if not client_host:
|
||||||
return False
|
return False
|
||||||
if client_host in ("127.0.0.1", "::1"):
|
if client_host in ("127.0.0.1", "::1"):
|
||||||
return True
|
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(".")
|
parts = client_host.split(".")
|
||||||
if len(parts) == 4:
|
if len(parts) == 4:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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.private_only": "The /calendar_link command is only available in private chat.",
|
||||||
"calendar_link.access_denied": "Access denied.",
|
"calendar_link.access_denied": "Access denied.",
|
||||||
"calendar_link.success": "Your personal calendar URL:\n{url}",
|
"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.",
|
"calendar_link.error": "Could not generate link. Please try again later.",
|
||||||
"help.import_schedule": "/import_duty_schedule — Import duty schedule (JSON)",
|
"help.import_schedule": "/import_duty_schedule — Import duty schedule (JSON)",
|
||||||
"errors.generic": "An error occurred. Please try again later.",
|
"errors.generic": "An error occurred. Please try again later.",
|
||||||
"pin_duty.group_only": "The /pin_duty command works only in groups.",
|
"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.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.pinned": "Duty message pinned.",
|
||||||
"pin_duty.failed": "Could not pin. Make sure the bot is an administrator with «Pin messages» permission.",
|
"pin_duty.failed": (
|
||||||
"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.",
|
"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.no_duty": "No duty at the moment.",
|
||||||
"duty.label": "Duty:",
|
"duty.label": "Duty:",
|
||||||
"import.admin_only": "Access for administrators only.",
|
"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.parse_time_error": "Could not parse time. Enter e.g.: 09:00 Europe/Moscow",
|
||||||
"import.send_json": "Send the duty-schedule file (JSON).",
|
"import.send_json": "Send the duty-schedule file (JSON).",
|
||||||
"import.need_json": "File must have .json extension.",
|
"import.need_json": "File must have .json extension.",
|
||||||
"import.parse_error": "File parse error: {error}",
|
"import.parse_error": "File parse error: {error}",
|
||||||
"import.import_error": "Import 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_unavailable": ", {count} unavailable",
|
||||||
"import.done_vacation": ", {count} vacation",
|
"import.done_vacation": ", {count} vacation",
|
||||||
"api.open_from_telegram": "Open the calendar from Telegram",
|
"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.auth_invalid": "Invalid auth data",
|
||||||
"api.access_denied": "Access denied",
|
"api.access_denied": "Access denied",
|
||||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
|
|||||||
ranges: list[tuple[date, date]] = []
|
ranges: list[tuple[date, date]] = []
|
||||||
start_d = end_d = sorted_dates[0]
|
start_d = end_d = sorted_dates[0]
|
||||||
for d in sorted_dates[1:]:
|
for d in sorted_dates[1:]:
|
||||||
|
# Merge consecutive days into one range; gap starts a new range
|
||||||
if (d - end_d).days == 1:
|
if (d - end_d).days == 1:
|
||||||
end_d = d
|
end_d = d
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ duty-teller = "duty_teller.run:main"
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0,<9.0",
|
"pytest>=8.0,<9.0",
|
||||||
"pytest-asyncio>=0.24,<1.0",
|
"pytest-asyncio>=0.24,<1.0",
|
||||||
|
"pytest-cov>=6.0,<7.0",
|
||||||
"httpx>=0.27,<1.0",
|
"httpx>=0.27,<1.0",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
@@ -47,5 +48,9 @@ version_path_separator = "os"
|
|||||||
line-length = 120
|
line-length = 120
|
||||||
target-version = ["py311"]
|
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]
|
[tool.pylint.messages_control]
|
||||||
disable = ["C0114", "C0115", "C0116"]
|
disable = ["C0114", "C0115", "C0116"]
|
||||||
|
|||||||
@@ -250,11 +250,25 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
|||||||
assert data[0]["full_name"] == "User A"
|
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")
|
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||||
def test_calendar_ical_404_unknown_token(mock_get_user, client):
|
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."""
|
||||||
mock_get_user.return_value = None
|
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 r.status_code == 404
|
||||||
assert "not found" in r.text.lower()
|
assert "not found" in r.text.lower()
|
||||||
mock_get_user.assert_called_once()
|
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 = (
|
mock_build_ics.return_value = (
|
||||||
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
|
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.status_code == 200
|
||||||
assert r.headers.get("content-type", "").startswith("text/calendar")
|
assert r.headers.get("content-type", "").startswith("text/calendar")
|
||||||
assert b"BEGIN:VCALENDAR" in r.content
|
assert b"BEGIN:VCALENDAR" in r.content
|
||||||
|
|||||||
Reference in New Issue
Block a user