- Added functionality to normalize phone numbers for comparison, ensuring only digits are stored and checked. - Updated configuration to include optional phone number allowlists for users and admins in the environment settings. - Enhanced authentication logic to allow access based on normalized phone numbers, in addition to usernames. - Introduced new helper functions for parsing and validating phone numbers, improving code organization and maintainability. - Added unit tests to validate phone normalization and access control based on phone numbers.
251 lines
9.5 KiB
Python
251 lines
9.5 KiB
Python
"""Tests for FastAPI app /api/duties."""
|
|
|
|
import time
|
|
from unittest.mock import ANY, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import duty_teller.config as config
|
|
from duty_teller.api.app import app
|
|
from tests.helpers import make_init_data
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(app)
|
|
|
|
|
|
def test_duties_invalid_date_format(client):
|
|
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
|
assert r.status_code == 400
|
|
detail = r.json()["detail"]
|
|
assert "from" in detail.lower() and "to" in detail.lower()
|
|
|
|
|
|
def test_duties_from_after_to(client):
|
|
r = client.get("/api/duties", params={"from": "2025-02-01", "to": "2025-01-01"})
|
|
assert r.status_code == 400
|
|
detail = r.json()["detail"].lower()
|
|
assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail
|
|
|
|
|
|
@patch("duty_teller.api.dependencies._is_private_client")
|
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
|
def test_duties_403_without_init_data_from_public_client(mock_private, client):
|
|
mock_private.return_value = False
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
|
@patch("duty_teller.api.app.fetch_duties_response")
|
|
def test_duties_200_when_skip_auth(mock_fetch, client):
|
|
mock_fetch.return_value = []
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
|
|
|
|
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
|
def test_duties_403_when_init_data_invalid(mock_validate, client):
|
|
mock_validate.return_value = (None, None, "hash_mismatch", "en")
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
headers={"X-Telegram-Init-Data": "some=data&hash=abc"},
|
|
)
|
|
assert r.status_code == 403
|
|
detail = r.json()["detail"]
|
|
assert (
|
|
"signature" in detail.lower()
|
|
or "авторизации" in detail
|
|
or "Неверные" in detail
|
|
or "Неверная" in detail
|
|
)
|
|
|
|
|
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
|
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
|
def test_duties_403_when_username_not_allowed(
|
|
mock_can_access, mock_validate, mock_get_user, client
|
|
):
|
|
mock_validate.return_value = (123, "someuser", "ok", "en")
|
|
mock_can_access.return_value = False
|
|
mock_get_user.return_value = None # no user in DB or no phone path
|
|
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
|
|
)
|
|
assert r.status_code == 403
|
|
assert (
|
|
"Access denied" in r.json()["detail"] or "Доступ запрещён" in r.json()["detail"]
|
|
)
|
|
mock_fetch.assert_not_called()
|
|
|
|
|
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp_by_phone")
|
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
|
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
|
def test_duties_403_when_no_username_and_phone_not_in_allowlist(
|
|
mock_can_access, mock_validate, mock_get_user, mock_can_access_phone, client
|
|
):
|
|
"""No username in initData and user's phone not in ALLOWED_PHONES -> 403."""
|
|
from types import SimpleNamespace
|
|
|
|
mock_validate.return_value = (456, None, "ok", "en")
|
|
mock_can_access.return_value = False
|
|
mock_get_user.return_value = SimpleNamespace(phone="+79001111111", full_name="User")
|
|
mock_can_access_phone.return_value = False
|
|
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
|
|
)
|
|
assert r.status_code == 403
|
|
mock_fetch.assert_not_called()
|
|
|
|
|
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp_by_phone")
|
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
|
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
|
def test_duties_200_when_no_username_but_phone_in_allowlist(
|
|
mock_can_access, mock_validate, mock_get_user, mock_can_access_phone, client
|
|
):
|
|
"""No username in initData but user's phone in ALLOWED_PHONES -> 200."""
|
|
from types import SimpleNamespace
|
|
|
|
mock_validate.return_value = (789, None, "ok", "en")
|
|
mock_can_access.return_value = False
|
|
mock_get_user.return_value = SimpleNamespace(
|
|
phone="+7 900 123-45-67", full_name="Иван"
|
|
)
|
|
mock_can_access_phone.return_value = True
|
|
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
|
|
mock_fetch.return_value = []
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
|
|
|
|
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
|
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
|
def test_duties_200_with_valid_init_data_and_skip_auth_not_in_allowlist(
|
|
mock_can_access, mock_validate, client
|
|
):
|
|
"""With MINI_APP_SKIP_AUTH=True, valid initData grants access without allowlist check."""
|
|
mock_validate.return_value = ("outsider_user", "ok", "en")
|
|
mock_can_access.return_value = False
|
|
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
|
|
mock_fetch.return_value = []
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
|
mock_can_access.assert_not_called()
|
|
mock_validate.assert_not_called()
|
|
|
|
|
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
|
|
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
|
|
mock_validate.return_value = (1, "alloweduser", "ok", "en")
|
|
mock_can_access.return_value = True
|
|
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
|
|
mock_fetch.return_value = [
|
|
{
|
|
"id": 1,
|
|
"user_id": 10,
|
|
"start_at": "2025-01-15T09:00:00Z",
|
|
"end_at": "2025-01-15T18:00:00Z",
|
|
"full_name": "Иван Иванов",
|
|
}
|
|
]
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert len(r.json()) == 1
|
|
assert r.json()[0]["full_name"] == "Иван Иванов"
|
|
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
|
|
|
|
|
def test_duties_e2e_auth_real_validation(client, monkeypatch):
|
|
test_token = "123:ABC"
|
|
test_username = "e2euser"
|
|
monkeypatch.setattr(config, "BOT_TOKEN", test_token)
|
|
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username})
|
|
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
|
|
monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0)
|
|
init_data = make_init_data(
|
|
{"id": 1, "username": test_username},
|
|
test_token,
|
|
auth_date=int(time.time()),
|
|
)
|
|
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
|
|
mock_fetch.return_value = []
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
headers={"X-Telegram-Init-Data": init_data},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
|
|
|
|
|
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
|
def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
|
from types import SimpleNamespace
|
|
|
|
fake_duty = SimpleNamespace(
|
|
id=1,
|
|
user_id=10,
|
|
start_at="2025-01-15T09:00:00Z",
|
|
end_at="2025-01-15T18:00:00Z",
|
|
event_type="unknown",
|
|
)
|
|
|
|
def fake_get_duties(session, from_date, to_date):
|
|
return [(fake_duty, "User A")]
|
|
|
|
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
|
|
r = client.get(
|
|
"/api/duties",
|
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert len(data) == 1
|
|
assert data[0]["event_type"] == "duty"
|
|
assert data[0]["full_name"] == "User A"
|