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:
2026-02-18 16:11:44 +03:00
parent d0d22c150a
commit 59ba2a9ca4
10 changed files with 344 additions and 48 deletions

View File

@@ -7,6 +7,10 @@ HTTP_PORT=8080
ALLOWED_USERNAMES=username1,username2 ALLOWED_USERNAMES=username1,username2
ADMIN_USERNAMES=admin1,admin2 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). # Dev only: set to 1 to allow calendar without Telegram initData (insecure; do not use in production).
# MINI_APP_SKIP_AUTH=1 # MINI_APP_SKIP_AUTH=1

View File

@@ -4,12 +4,12 @@ import logging
import re import re
from typing import Annotated, Generator 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 from sqlalchemy.orm import Session
import duty_teller.config as config import duty_teller.config as config
from duty_teller.api.telegram_auth import validate_init_data_with_reason 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.schemas import DutyWithUser
from duty_teller.db.session import session_scope from duty_teller.db.session import session_scope
from duty_teller.i18n import t from duty_teller.i18n import t
@@ -74,8 +74,9 @@ def require_miniapp_username(
x_telegram_init_data: Annotated[ x_telegram_init_data: Annotated[
str | None, Header(alias="X-Telegram-Init-Data") str | None, Header(alias="X-Telegram-Init-Data")
] = None, ] = None,
session: Session = Depends(get_db_session),
) -> str: ) -> 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: 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( def get_authenticated_username(
request: Request, x_telegram_init_data: str | None request: Request,
x_telegram_init_data: str | None,
session: Session,
) -> str: ) -> 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() init_data = (x_telegram_init_data or "").strip()
if not init_data: if not init_data:
client_host = request.client.host if request.client else None client_host = request.client.host if request.client else None
if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH: if _is_private_client(client_host):
if config.MINI_APP_SKIP_AUTH:
log.warning("allowing without initData (MINI_APP_SKIP_AUTH is set)")
return "" return ""
log.warning("no X-Telegram-Init-Data header (client=%s)", client_host) log.warning("no X-Telegram-Init-Data header (client=%s)", client_host)
lang = _lang_from_accept_language(request.headers.get("Accept-Language")) lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram")) raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None 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 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) log.warning("initData validation failed: %s", auth_reason)
raise HTTPException( raise HTTPException(
status_code=403, detail=_auth_error_detail(auth_reason, lang) status_code=403, detail=_auth_error_detail(auth_reason, lang)
) )
if not config.can_access_miniapp(username): if username and 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 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( def fetch_duties_response(

View File

@@ -16,7 +16,7 @@ def validate_init_data(
max_age_seconds: int | None = None, max_age_seconds: int | None = None,
) -> str | None: ) -> str | None:
"""Validate initData and return username; see validate_init_data_with_reason for failure reason.""" """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 init_data, bot_token, max_age_seconds
) )
return username return username
@@ -34,14 +34,16 @@ def validate_init_data_with_reason(
init_data: str, init_data: str,
bot_token: str, bot_token: str,
max_age_seconds: int | None = None, 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). 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_username". 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. 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: if not init_data or not bot_token:
return (None, "empty", "en") return (None, None, "empty", "en")
init_data = init_data.strip() init_data = init_data.strip()
params = {} params = {}
for part in init_data.split("&"): for part in init_data.split("&"):
@@ -53,7 +55,7 @@ def validate_init_data_with_reason(
params[key] = value params[key] = value
hash_val = params.pop("hash", None) hash_val = params.pop("hash", None)
if not hash_val: if not hash_val:
return (None, "no_hash", "en") return (None, None, "no_hash", "en")
data_pairs = sorted(params.items()) data_pairs = sorted(params.items())
# Data-check string: key=value with URL-decoded values (per Telegram example) # 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) 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, digestmod=hashlib.sha256,
).hexdigest() ).hexdigest()
if not hmac.compare_digest(computed.lower(), hash_val.lower()): 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: if max_age_seconds is not None and max_age_seconds > 0:
auth_date_raw = params.get("auth_date") auth_date_raw = params.get("auth_date")
if not auth_date_raw: if not auth_date_raw:
return (None, "auth_date_expired", "en") return (None, None, "auth_date_expired", "en")
try: try:
auth_date = int(float(auth_date_raw)) auth_date = int(float(auth_date_raw))
except (ValueError, TypeError): except (ValueError, TypeError):
return (None, "auth_date_expired", "en") return (None, None, "auth_date_expired", "en")
if time.time() - auth_date > max_age_seconds: 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") user_raw = params.get("user")
if not user_raw: if not user_raw:
return (None, "no_user", "en") return (None, None, "no_user", "en")
try: try:
user = json.loads(unquote(user_raw)) user = json.loads(unquote(user_raw))
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return (None, "user_invalid", "en") return (None, None, "user_invalid", "en")
if not isinstance(user, dict): if not isinstance(user, dict):
return (None, "user_invalid", "en") return (None, None, "user_invalid", "en")
lang = _normalize_lang(user.get("language_code")) 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") username = user.get("username")
if not username or not isinstance(username, str): if username and isinstance(username, str):
return (None, "no_username", lang) username = username.strip().lstrip("@").lower()
return (username.strip().lstrip("@").lower(), "ok", lang) else:
username = None
return (telegram_user_id, username, "ok", lang)

View File

@@ -1,5 +1,6 @@
"""Load configuration from environment. BOT_TOKEN is not validated on import; check in main/entry point.""" """Load configuration from environment. BOT_TOKEN is not validated on import; check in main/entry point."""
import re
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -11,6 +12,16 @@ load_dotenv()
# Project root (parent of duty_teller package). Used for webapp path, etc. # Project root (parent of duty_teller package). Used for webapp path, etc.
PROJECT_ROOT = Path(__file__).resolve().parent.parent 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: def _normalize_default_language(value: str) -> str:
"""Normalize DEFAULT_LANGUAGE from env to 'ru' or 'en'.""" """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" 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) @dataclass(frozen=True)
class Settings: class Settings:
"""Optional injectable settings built from env. Tests can override or build from env.""" """Optional injectable settings built from env. Tests can override or build from env."""
@@ -30,6 +51,8 @@ class Settings:
http_port: int http_port: int
allowed_usernames: set[str] allowed_usernames: set[str]
admin_usernames: set[str] admin_usernames: set[str]
allowed_phones: set[str]
admin_phones: set[str]
mini_app_skip_auth: bool mini_app_skip_auth: bool
init_data_max_age_seconds: int init_data_max_age_seconds: int
cors_origins: list[str] cors_origins: list[str]
@@ -49,6 +72,8 @@ class Settings:
admin = { admin = {
s.strip().lstrip("@").lower() for s in raw_admin.split(",") if s.strip() 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() raw_cors = os.getenv("CORS_ORIGINS", "").strip()
cors = ( cors = (
[_o.strip() for _o in raw_cors.split(",") if _o.strip()] [_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")), http_port=int(os.getenv("HTTP_PORT", "8080")),
allowed_usernames=allowed, allowed_usernames=allowed,
admin_usernames=admin, admin_usernames=admin,
allowed_phones=allowed_phones,
admin_phones=admin_phones,
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip() mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
in ("1", "true", "yes"), in ("1", "true", "yes"),
init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")), 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() 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") 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")) 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 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: def require_bot_token() -> None:
"""Raise SystemExit with a clear message if BOT_TOKEN is not set. Call from entry point.""" """Raise SystemExit with a clear message if BOT_TOKEN is not set. Call from entry point."""
if not BOT_TOKEN: if not BOT_TOKEN:

View File

@@ -7,6 +7,11 @@ from sqlalchemy.orm import Session
from duty_teller.db.models import User, Duty, GroupDutyPin 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( def get_or_create_user(
session: Session, session: Session,
telegram_user_id: int, telegram_user_id: int,
@@ -15,7 +20,7 @@ def get_or_create_user(
first_name: str | None = None, first_name: str | None = None,
last_name: str | None = None, last_name: str | None = None,
) -> User: ) -> 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: if user:
user.full_name = full_name user.full_name = full_name
user.username = username user.username = username

View File

@@ -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.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): 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( r = client.get(
"/api/duties", "/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"}, 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.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.can_access_miniapp") @patch("duty_teller.api.dependencies.config.can_access_miniapp")
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client): @patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
mock_validate.return_value = ("someuser", "ok", "en") 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_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: with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
r = client.get( r = client.get(
"/api/duties", "/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() 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.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.can_access_miniapp") @patch("duty_teller.api.dependencies.config.can_access_miniapp")
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): 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 mock_can_access.return_value = True
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
mock_fetch.return_value = [ mock_fetch.return_value = [

View File

@@ -34,3 +34,36 @@ def test_can_access_miniapp_denied(monkeypatch):
monkeypatch.setattr(config, "ADMIN_USERNAMES", set()) monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
assert config.can_access_miniapp("other") is False assert config.can_access_miniapp("other") is False
assert config.can_access_miniapp("") 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

View File

@@ -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 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 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" bot_token = "123:ABC"
user = {"id": 123, "first_name": "Test"} # no username user = {"id": 123, "first_name": "Test"} # no username
init_data = make_init_data(user, bot_token) init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) is None 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(): def test_empty_init_data_returns_none():
assert validate_init_data("", "token") is None assert validate_init_data("", "token") is None
assert validate_init_data(" ", "token") is None assert validate_init_data(" ", "token") is None

View File

@@ -295,13 +295,13 @@
let html = "<span class=\"num\">" + d.getDate() + "</span><div class=\"day-markers\">"; let html = "<span class=\"num\">" + d.getDate() + "</span><div class=\"day-markers\">";
if (showMarkers) { if (showMarkers) {
if (dutyList.length) { 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) { 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) { 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) { if (hasEvent) {
html += "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>"; 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) { btn.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
var summary = btn.getAttribute("data-summary") || ""; var summary = btn.getAttribute("data-summary") || "";
if (hintEl.hidden || hintEl.textContent !== summary) { var content = "События:\n" + summary;
hintEl.textContent = summary; if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
var rect = btn.getBoundingClientRect(); var rect = btn.getBoundingClientRect();
positionHint(hintEl, rect); positionHint(hintEl, rect);
hintEl.dataset.active = "1"; 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() { function bindDutyMarkerTooltips() {
var hintEl = document.getElementById("dutyMarkerHint"); var hintEl = document.getElementById("dutyMarkerHint");
if (!hintEl) { if (!hintEl) {
@@ -418,22 +433,53 @@
clearTimeout(hideTimeout); clearTimeout(hideTimeout);
hideTimeout = null; hideTimeout = null;
} }
var names = marker.getAttribute("data-names") || ""; hintEl.textContent = getDutyMarkerHintContent(marker);
hintEl.textContent = names;
var rect = marker.getBoundingClientRect(); var rect = marker.getBoundingClientRect();
positionHint(hintEl, rect); positionHint(hintEl, rect);
hintEl.hidden = false; hintEl.hidden = false;
}); });
marker.addEventListener("mouseleave", function () { marker.addEventListener("mouseleave", function () {
if (hintEl.dataset.active) { return; }
hideTimeout = setTimeout(function () { hideTimeout = setTimeout(function () {
hintEl.hidden = true; hintEl.hidden = true;
hideTimeout = null; hideTimeout = null;
}, 150); }, 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 UTC date from ISO string as DD.MM for display. */
/** Format date as DD.MM in user's local timezone (for duty card labels). */ /** Format date as DD.MM in user's local timezone (for duty card labels). */
@@ -540,9 +586,19 @@
dutyListEl.innerHTML = fullHtml; dutyListEl.innerHTML = fullHtml;
var scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today"); var scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today");
if (scrollTarget) { if (scrollTarget) {
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" }); scrollTarget.scrollIntoView({ behavior: "auto", block: "start" });
} }
} }
}
function escapeHtml(s) { function escapeHtml(s) {
const div = document.createElement("div"); const div = document.createElement("div");

View File

@@ -245,7 +245,7 @@ body {
transform: none; transform: none;
} }
/* Единый размер маркеров (11px), чтобы и один, и три в строке выглядели одинаково */ /* Маркеры: компактный размер 11px, кнопки для доступности и тапа */
.duty-marker, .duty-marker,
.unavailable-marker, .unavailable-marker,
.vacation-marker { .vacation-marker {
@@ -254,10 +254,13 @@ body {
justify-content: center; justify-content: center;
width: 11px; width: 11px;
height: 11px; height: 11px;
padding: 0;
border: none;
font-size: 0.55rem; font-size: 0.55rem;
font-weight: 700; font-weight: 700;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
} }
.duty-marker { .duty-marker {
@@ -275,6 +278,18 @@ body {
background: color-mix(in srgb, var(--vacation) 25%, transparent); 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 { .duty-list {
font-size: 0.9rem; font-size: 0.9rem;
} }