Implement phone number normalization and access control for Telegram users
- 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.
This commit is contained in:
@@ -7,6 +7,10 @@ HTTP_PORT=8080
|
||||
ALLOWED_USERNAMES=username1,username2
|
||||
ADMIN_USERNAMES=admin1,admin2
|
||||
|
||||
# Optional: allow by phone (user sets phone via /set_phone in bot). Comma-separated; normalized to digits for comparison.
|
||||
# ALLOWED_PHONES=79001234567,79007654321
|
||||
# ADMIN_PHONES=79001111111
|
||||
|
||||
# Dev only: set to 1 to allow calendar without Telegram initData (insecure; do not use in production).
|
||||
# MINI_APP_SKIP_AUTH=1
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import logging
|
||||
import re
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Header, HTTPException, Query, Request
|
||||
from fastapi import Depends, Header, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.api.telegram_auth import validate_init_data_with_reason
|
||||
from duty_teller.db.repository import get_duties
|
||||
from duty_teller.db.repository import get_duties, get_user_by_telegram_id
|
||||
from duty_teller.db.schemas import DutyWithUser
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import t
|
||||
@@ -74,8 +74,9 @@ def require_miniapp_username(
|
||||
x_telegram_init_data: Annotated[
|
||||
str | None, Header(alias="X-Telegram-Init-Data")
|
||||
] = None,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
return get_authenticated_username(request, x_telegram_init_data)
|
||||
return get_authenticated_username(request, x_telegram_init_data, session)
|
||||
|
||||
|
||||
def _is_private_client(client_host: str | None) -> bool:
|
||||
@@ -95,31 +96,43 @@ def _is_private_client(client_host: str | None) -> bool:
|
||||
|
||||
|
||||
def get_authenticated_username(
|
||||
request: Request, x_telegram_init_data: str | None
|
||||
request: Request,
|
||||
x_telegram_init_data: str | None,
|
||||
session: Session,
|
||||
) -> str:
|
||||
"""Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth."""
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
log.warning("allowing without any auth check (MINI_APP_SKIP_AUTH is set)")
|
||||
return ""
|
||||
init_data = (x_telegram_init_data or "").strip()
|
||||
if not init_data:
|
||||
client_host = request.client.host if request.client else None
|
||||
if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH:
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
log.warning("allowing without initData (MINI_APP_SKIP_AUTH is set)")
|
||||
if _is_private_client(client_host):
|
||||
return ""
|
||||
log.warning("no X-Telegram-Init-Data header (client=%s)", client_host)
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
|
||||
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
|
||||
username, auth_reason, lang = validate_init_data_with_reason(
|
||||
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
|
||||
init_data, config.BOT_TOKEN, max_age_seconds=max_age
|
||||
)
|
||||
if username is None:
|
||||
if auth_reason != "ok":
|
||||
log.warning("initData validation failed: %s", auth_reason)
|
||||
raise HTTPException(
|
||||
status_code=403, detail=_auth_error_detail(auth_reason, lang)
|
||||
)
|
||||
if not config.can_access_miniapp(username):
|
||||
log.warning("username not in allowlist: %s", username)
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
return username
|
||||
if username and config.can_access_miniapp(username):
|
||||
return username
|
||||
if telegram_user_id is not None:
|
||||
user = get_user_by_telegram_id(session, telegram_user_id)
|
||||
if user and user.phone and config.can_access_miniapp_by_phone(user.phone):
|
||||
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
||||
log.warning(
|
||||
"username/phone not in allowlist (username=%s, telegram_id=%s)",
|
||||
username,
|
||||
telegram_user_id,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
|
||||
|
||||
def fetch_duties_response(
|
||||
|
||||
@@ -16,7 +16,7 @@ def validate_init_data(
|
||||
max_age_seconds: int | None = None,
|
||||
) -> str | None:
|
||||
"""Validate initData and return username; see validate_init_data_with_reason for failure reason."""
|
||||
username, _, _ = validate_init_data_with_reason(
|
||||
_, username, _, _ = validate_init_data_with_reason(
|
||||
init_data, bot_token, max_age_seconds
|
||||
)
|
||||
return username
|
||||
@@ -34,14 +34,16 @@ def validate_init_data_with_reason(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
max_age_seconds: int | None = None,
|
||||
) -> tuple[str | None, str, str]:
|
||||
) -> tuple[int | None, str | None, str, str]:
|
||||
"""
|
||||
Validate initData signature and return (username, reason, lang) or (None, reason, lang).
|
||||
reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user", "user_invalid", "no_username".
|
||||
Validate initData signature and return (telegram_user_id, username, reason, lang).
|
||||
reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired",
|
||||
"no_user", "user_invalid", "no_user_id", "no_username" (legacy; no_user_id used when user.id missing).
|
||||
lang is from user.language_code normalized to 'ru' or 'en'; 'en' when no user.
|
||||
On success: (user.id, user.get('username') or None, "ok", lang) — username may be None.
|
||||
"""
|
||||
if not init_data or not bot_token:
|
||||
return (None, "empty", "en")
|
||||
return (None, None, "empty", "en")
|
||||
init_data = init_data.strip()
|
||||
params = {}
|
||||
for part in init_data.split("&"):
|
||||
@@ -53,7 +55,7 @@ def validate_init_data_with_reason(
|
||||
params[key] = value
|
||||
hash_val = params.pop("hash", None)
|
||||
if not hash_val:
|
||||
return (None, "no_hash", "en")
|
||||
return (None, None, "no_hash", "en")
|
||||
data_pairs = sorted(params.items())
|
||||
# Data-check string: key=value with URL-decoded values (per Telegram example)
|
||||
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
|
||||
@@ -69,28 +71,37 @@ def validate_init_data_with_reason(
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
||||
return (None, "hash_mismatch", "en")
|
||||
return (None, None, "hash_mismatch", "en")
|
||||
if max_age_seconds is not None and max_age_seconds > 0:
|
||||
auth_date_raw = params.get("auth_date")
|
||||
if not auth_date_raw:
|
||||
return (None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
try:
|
||||
auth_date = int(float(auth_date_raw))
|
||||
except (ValueError, TypeError):
|
||||
return (None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return (None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
user_raw = params.get("user")
|
||||
if not user_raw:
|
||||
return (None, "no_user", "en")
|
||||
return (None, None, "no_user", "en")
|
||||
try:
|
||||
user = json.loads(unquote(user_raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (None, "user_invalid", "en")
|
||||
return (None, None, "user_invalid", "en")
|
||||
if not isinstance(user, dict):
|
||||
return (None, "user_invalid", "en")
|
||||
return (None, None, "user_invalid", "en")
|
||||
lang = _normalize_lang(user.get("language_code"))
|
||||
raw_id = user.get("id")
|
||||
if raw_id is None:
|
||||
return (None, None, "no_user_id", lang)
|
||||
try:
|
||||
telegram_user_id = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
return (None, None, "no_user_id", lang)
|
||||
username = user.get("username")
|
||||
if not username or not isinstance(username, str):
|
||||
return (None, "no_username", lang)
|
||||
return (username.strip().lstrip("@").lower(), "ok", lang)
|
||||
if username and isinstance(username, str):
|
||||
username = username.strip().lstrip("@").lower()
|
||||
else:
|
||||
username = None
|
||||
return (telegram_user_id, username, "ok", lang)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Load configuration from environment. BOT_TOKEN is not validated on import; check in main/entry point."""
|
||||
|
||||
import re
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -11,6 +12,16 @@ load_dotenv()
|
||||
# Project root (parent of duty_teller package). Used for webapp path, etc.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Only digits for phone comparison (same when saving and when checking allowlist).
|
||||
_PHONE_DIGITS_RE = re.compile(r"\D")
|
||||
|
||||
|
||||
def normalize_phone(phone: str | None) -> str:
|
||||
"""Return phone as digits only (spaces, +, parentheses, dashes removed). Empty string if None/empty."""
|
||||
if not phone or not isinstance(phone, str):
|
||||
return ""
|
||||
return _PHONE_DIGITS_RE.sub("", phone.strip())
|
||||
|
||||
|
||||
def _normalize_default_language(value: str) -> str:
|
||||
"""Normalize DEFAULT_LANGUAGE from env to 'ru' or 'en'."""
|
||||
@@ -20,6 +31,16 @@ def _normalize_default_language(value: str) -> str:
|
||||
return "ru" if v.startswith("ru") else "en"
|
||||
|
||||
|
||||
def _parse_phone_list(raw: str) -> set[str]:
|
||||
"""Parse comma-separated phones into set of normalized (digits-only) strings."""
|
||||
result = set()
|
||||
for s in (raw or "").strip().split(","):
|
||||
normalized = normalize_phone(s)
|
||||
if normalized:
|
||||
result.add(normalized)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Optional injectable settings built from env. Tests can override or build from env."""
|
||||
@@ -30,6 +51,8 @@ class Settings:
|
||||
http_port: int
|
||||
allowed_usernames: set[str]
|
||||
admin_usernames: set[str]
|
||||
allowed_phones: set[str]
|
||||
admin_phones: set[str]
|
||||
mini_app_skip_auth: bool
|
||||
init_data_max_age_seconds: int
|
||||
cors_origins: list[str]
|
||||
@@ -49,6 +72,8 @@ class Settings:
|
||||
admin = {
|
||||
s.strip().lstrip("@").lower() for s in raw_admin.split(",") if s.strip()
|
||||
}
|
||||
allowed_phones = _parse_phone_list(os.getenv("ALLOWED_PHONES", ""))
|
||||
admin_phones = _parse_phone_list(os.getenv("ADMIN_PHONES", ""))
|
||||
raw_cors = os.getenv("CORS_ORIGINS", "").strip()
|
||||
cors = (
|
||||
[_o.strip() for _o in raw_cors.split(",") if _o.strip()]
|
||||
@@ -62,6 +87,8 @@ class Settings:
|
||||
http_port=int(os.getenv("HTTP_PORT", "8080")),
|
||||
allowed_usernames=allowed,
|
||||
admin_usernames=admin,
|
||||
allowed_phones=allowed_phones,
|
||||
admin_phones=admin_phones,
|
||||
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
|
||||
in ("1", "true", "yes"),
|
||||
init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")),
|
||||
@@ -93,6 +120,9 @@ ADMIN_USERNAMES = {
|
||||
s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()
|
||||
}
|
||||
|
||||
ALLOWED_PHONES = _parse_phone_list(os.getenv("ALLOWED_PHONES", ""))
|
||||
ADMIN_PHONES = _parse_phone_list(os.getenv("ADMIN_PHONES", ""))
|
||||
|
||||
MINI_APP_SKIP_AUTH = os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes")
|
||||
INIT_DATA_MAX_AGE_SECONDS = int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0"))
|
||||
|
||||
@@ -123,6 +153,20 @@ def can_access_miniapp(username: str) -> bool:
|
||||
return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES
|
||||
|
||||
|
||||
def can_access_miniapp_by_phone(phone: str | None) -> bool:
|
||||
"""True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES."""
|
||||
normalized = normalize_phone(phone)
|
||||
if not normalized:
|
||||
return False
|
||||
return normalized in ALLOWED_PHONES or normalized in ADMIN_PHONES
|
||||
|
||||
|
||||
def is_admin_by_phone(phone: str | None) -> bool:
|
||||
"""True if normalized phone is in ADMIN_PHONES."""
|
||||
normalized = normalize_phone(phone)
|
||||
return bool(normalized and normalized in ADMIN_PHONES)
|
||||
|
||||
|
||||
def require_bot_token() -> None:
|
||||
"""Raise SystemExit with a clear message if BOT_TOKEN is not set. Call from entry point."""
|
||||
if not BOT_TOKEN:
|
||||
|
||||
@@ -7,6 +7,11 @@ from sqlalchemy.orm import Session
|
||||
from duty_teller.db.models import User, Duty, GroupDutyPin
|
||||
|
||||
|
||||
def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None:
|
||||
"""Find user by telegram_user_id. Returns None if not found (no creation)."""
|
||||
return session.query(User).filter(User.telegram_user_id == telegram_user_id).first()
|
||||
|
||||
|
||||
def get_or_create_user(
|
||||
session: Session,
|
||||
telegram_user_id: int,
|
||||
@@ -15,7 +20,7 @@ def get_or_create_user(
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> User:
|
||||
user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()
|
||||
user = get_user_by_telegram_id(session, telegram_user_id)
|
||||
if user:
|
||||
user.full_name = full_name
|
||||
user.username = username
|
||||
|
||||
@@ -55,8 +55,9 @@ def test_duties_200_when_skip_auth(mock_fetch, client):
|
||||
|
||||
|
||||
@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, "hash_mismatch", "en")
|
||||
mock_validate.return_value = (None, None, "hash_mismatch", "en")
|
||||
r = client.get(
|
||||
"/api/duties",
|
||||
params={"from": "2025-01-01", "to": "2025-01-31"},
|
||||
@@ -72,11 +73,16 @@ def test_duties_403_when_init_data_invalid(mock_validate, client):
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client):
|
||||
mock_validate.return_value = ("someuser", "ok", "en")
|
||||
@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",
|
||||
@@ -90,10 +96,87 @@ def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, cl
|
||||
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 = ("alloweduser", "ok", "en")
|
||||
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 = [
|
||||
|
||||
@@ -34,3 +34,36 @@ def test_can_access_miniapp_denied(monkeypatch):
|
||||
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
|
||||
assert config.can_access_miniapp("other") is False
|
||||
assert config.can_access_miniapp("") is False
|
||||
|
||||
|
||||
def test_normalize_phone():
|
||||
"""Phone is normalized to digits only."""
|
||||
assert config.normalize_phone("+7 900 123-45-67") == "79001234567"
|
||||
assert config.normalize_phone("8 (900) 123-45-67") == "89001234567"
|
||||
assert config.normalize_phone("79001234567") == "79001234567"
|
||||
assert config.normalize_phone("") == ""
|
||||
assert config.normalize_phone(None) == ""
|
||||
|
||||
|
||||
def test_can_access_miniapp_by_phone(monkeypatch):
|
||||
monkeypatch.setattr(config, "ALLOWED_PHONES", {"79001234567", "79007654321"})
|
||||
monkeypatch.setattr(config, "ADMIN_PHONES", set())
|
||||
assert config.can_access_miniapp_by_phone("+7 900 123-45-67") is True
|
||||
assert config.can_access_miniapp_by_phone("79007654321") is True
|
||||
assert config.can_access_miniapp_by_phone("79001111111") is False
|
||||
assert config.can_access_miniapp_by_phone(None) is False
|
||||
assert config.can_access_miniapp_by_phone("") is False
|
||||
|
||||
|
||||
def test_can_access_miniapp_by_phone_admin(monkeypatch):
|
||||
monkeypatch.setattr(config, "ALLOWED_PHONES", set())
|
||||
monkeypatch.setattr(config, "ADMIN_PHONES", {"79001111111"})
|
||||
assert config.can_access_miniapp_by_phone("+7 900 111-11-11") is True
|
||||
assert config.can_access_miniapp_by_phone("79002222222") is False
|
||||
|
||||
|
||||
def test_is_admin_by_phone(monkeypatch):
|
||||
monkeypatch.setattr(config, "ADMIN_PHONES", {"79001111111"})
|
||||
assert config.is_admin_by_phone("+7 900 111-11-11") is True
|
||||
assert config.is_admin_by_phone("79001234567") is False
|
||||
assert config.is_admin_by_phone(None) is False
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Tests for duty_teller.api.telegram_auth.validate_init_data."""
|
||||
"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason."""
|
||||
|
||||
from duty_teller.api.telegram_auth import validate_init_data
|
||||
from duty_teller.api.telegram_auth import (
|
||||
validate_init_data,
|
||||
validate_init_data_with_reason,
|
||||
)
|
||||
from tests.helpers import make_init_data
|
||||
|
||||
|
||||
@@ -40,13 +43,42 @@ def test_missing_user_returns_none():
|
||||
assert validate_init_data(init_data, bot_token) is None
|
||||
|
||||
|
||||
def test_user_without_username_returns_none():
|
||||
def test_user_without_username_returns_none_from_validate_init_data():
|
||||
"""validate_init_data returns None when user has no username (backward compat)."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"id": 123, "first_name": "Test"} # no username
|
||||
init_data = make_init_data(user, bot_token)
|
||||
assert validate_init_data(init_data, bot_token) is None
|
||||
|
||||
|
||||
def test_user_without_username_but_with_id_succeeds_with_reason():
|
||||
"""With validate_init_data_with_reason, valid user.id is enough; username may be None."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
|
||||
init_data = make_init_data(user, bot_token)
|
||||
telegram_user_id, username, reason, lang = validate_init_data_with_reason(
|
||||
init_data, bot_token
|
||||
)
|
||||
assert telegram_user_id == 456
|
||||
assert username is None
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
|
||||
|
||||
def test_user_without_id_returns_no_user_id():
|
||||
"""When user object exists but has no 'id', return no_user_id."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"first_name": "Test"} # no id
|
||||
init_data = make_init_data(user, bot_token)
|
||||
telegram_user_id, username, reason, lang = validate_init_data_with_reason(
|
||||
init_data, bot_token
|
||||
)
|
||||
assert telegram_user_id is None
|
||||
assert username is None
|
||||
assert reason == "no_user_id"
|
||||
assert lang == "en"
|
||||
|
||||
|
||||
def test_empty_init_data_returns_none():
|
||||
assert validate_init_data("", "token") is None
|
||||
assert validate_init_data(" ", "token") is None
|
||||
|
||||
@@ -295,13 +295,13 @@
|
||||
let html = "<span class=\"num\">" + d.getDate() + "</span><div class=\"day-markers\">";
|
||||
if (showMarkers) {
|
||||
if (dutyList.length) {
|
||||
html += "<span class=\"duty-marker\" data-names=\"" + namesAttr(dutyList) + "\" title=\"" + titleAttr(dutyList) + "\" aria-label=\"Дежурные\">Д</span>";
|
||||
html += "<button type=\"button\" class=\"duty-marker\" data-event-type=\"duty\" data-names=\"" + namesAttr(dutyList) + "\" title=\"" + titleAttr(dutyList) + "\" aria-label=\"Дежурные\">Д</button>";
|
||||
}
|
||||
if (unavailableList.length) {
|
||||
html += "<span class=\"unavailable-marker\" data-names=\"" + namesAttr(unavailableList) + "\" title=\"" + titleAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</span>";
|
||||
html += "<button type=\"button\" class=\"unavailable-marker\" data-event-type=\"unavailable\" data-names=\"" + namesAttr(unavailableList) + "\" title=\"" + titleAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</button>";
|
||||
}
|
||||
if (vacationList.length) {
|
||||
html += "<span class=\"vacation-marker\" data-names=\"" + namesAttr(vacationList) + "\" title=\"" + titleAttr(vacationList) + "\" aria-label=\"Отпуск\">О</span>";
|
||||
html += "<button type=\"button\" class=\"vacation-marker\" data-event-type=\"vacation\" data-names=\"" + namesAttr(vacationList) + "\" title=\"" + titleAttr(vacationList) + "\" aria-label=\"Отпуск\">О</button>";
|
||||
}
|
||||
if (hasEvent) {
|
||||
html += "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>";
|
||||
@@ -372,8 +372,9 @@
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
var summary = btn.getAttribute("data-summary") || "";
|
||||
if (hintEl.hidden || hintEl.textContent !== summary) {
|
||||
hintEl.textContent = summary;
|
||||
var content = "События:\n" + summary;
|
||||
if (hintEl.hidden || hintEl.textContent !== content) {
|
||||
hintEl.textContent = content;
|
||||
var rect = btn.getBoundingClientRect();
|
||||
positionHint(hintEl, rect);
|
||||
hintEl.dataset.active = "1";
|
||||
@@ -400,6 +401,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
|
||||
|
||||
function getDutyMarkerHintContent(marker) {
|
||||
var type = marker.getAttribute("data-event-type") || "duty";
|
||||
var label = EVENT_TYPE_LABELS[type] || type;
|
||||
var names = (marker.getAttribute("data-names") || "").replace(/\n/g, ", ");
|
||||
return names ? label + ": " + names : label;
|
||||
}
|
||||
|
||||
function clearActiveDutyMarker() {
|
||||
calendarEl.querySelectorAll(".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active")
|
||||
.forEach(function (m) { m.classList.remove("calendar-marker-active"); });
|
||||
}
|
||||
|
||||
function bindDutyMarkerTooltips() {
|
||||
var hintEl = document.getElementById("dutyMarkerHint");
|
||||
if (!hintEl) {
|
||||
@@ -418,23 +433,54 @@
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
var names = marker.getAttribute("data-names") || "";
|
||||
hintEl.textContent = names;
|
||||
hintEl.textContent = getDutyMarkerHintContent(marker);
|
||||
var rect = marker.getBoundingClientRect();
|
||||
positionHint(hintEl, rect);
|
||||
hintEl.hidden = false;
|
||||
});
|
||||
marker.addEventListener("mouseleave", function () {
|
||||
if (hintEl.dataset.active) { return; }
|
||||
hideTimeout = setTimeout(function () {
|
||||
hintEl.hidden = true;
|
||||
hideTimeout = null;
|
||||
}, 150);
|
||||
});
|
||||
marker.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
if (marker.classList.contains("calendar-marker-active")) {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
marker.classList.remove("calendar-marker-active");
|
||||
return;
|
||||
}
|
||||
clearActiveDutyMarker();
|
||||
hintEl.textContent = getDutyMarkerHintContent(marker);
|
||||
var rect = marker.getBoundingClientRect();
|
||||
positionHint(hintEl, rect);
|
||||
hintEl.hidden = false;
|
||||
hintEl.dataset.active = "1";
|
||||
marker.classList.add("calendar-marker-active");
|
||||
});
|
||||
});
|
||||
if (!document._dutyMarkerHintBound) {
|
||||
document._dutyMarkerHintBound = true;
|
||||
document.addEventListener("click", function () {
|
||||
if (hintEl.dataset.active) {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
clearActiveDutyMarker();
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && hintEl.dataset.active) {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
clearActiveDutyMarker();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
|
||||
|
||||
/** Format UTC date from ISO string as DD.MM for display. */
|
||||
/** Format date as DD.MM in user's local timezone (for duty card labels). */
|
||||
function formatDateKey(isoDateStr) {
|
||||
@@ -540,7 +586,17 @@
|
||||
dutyListEl.innerHTML = fullHtml;
|
||||
var scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today");
|
||||
if (scrollTarget) {
|
||||
scrollTarget.scrollIntoView({ behavior: "auto", block: "start" });
|
||||
var calendarSticky = document.getElementById("calendarSticky");
|
||||
if (calendarSticky) {
|
||||
requestAnimationFrame(function () {
|
||||
var calendarHeight = calendarSticky.offsetHeight;
|
||||
var todayTop = scrollTarget.getBoundingClientRect().top + window.scrollY;
|
||||
var scrollTop = Math.max(0, todayTop - calendarHeight);
|
||||
window.scrollTo({ top: scrollTop, behavior: "auto" });
|
||||
});
|
||||
} else {
|
||||
scrollTarget.scrollIntoView({ behavior: "auto", block: "start" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ body {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Единый размер маркеров (11px), чтобы и один, и три в строке выглядели одинаково */
|
||||
/* Маркеры: компактный размер 11px, кнопки для доступности и тапа */
|
||||
.duty-marker,
|
||||
.unavailable-marker,
|
||||
.vacation-marker {
|
||||
@@ -254,10 +254,13 @@ body {
|
||||
justify-content: center;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duty-marker {
|
||||
@@ -275,6 +278,18 @@ body {
|
||||
background: color-mix(in srgb, var(--vacation) 25%, transparent);
|
||||
}
|
||||
|
||||
.duty-marker.calendar-marker-active {
|
||||
box-shadow: 0 0 0 2px var(--duty);
|
||||
}
|
||||
|
||||
.unavailable-marker.calendar-marker-active {
|
||||
box-shadow: 0 0 0 2px var(--unavailable);
|
||||
}
|
||||
|
||||
.vacation-marker.calendar-marker-active {
|
||||
box-shadow: 0 0 0 2px var(--vacation);
|
||||
}
|
||||
|
||||
.duty-list {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user