feat: аккаунты по почте с подтверждением, сбросом пароля и синхронизацией прогресса

API аккаунтов на чистой стандартной библиотеке Python (server/splitapi):
- регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения
- вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта
- пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF
- хранение прогресса пользователя с оптимистичной блокировкой версии
- SMTP через переменные окружения; без SMTP на публичном адресе API не стартует

Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js
(чистое слияние прогресса). Без API сайт работает как раньше.

Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/
в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example.

Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния.
Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
This commit is contained in:
darko
2026-10-16 11:25:09 +03:00
parent e897a6c5eb
commit fcafe1ae15
36 changed files with 4520 additions and 22 deletions
+1
View File
@@ -0,0 +1 @@
"""Минимальный API авторизации для Сплит-тренера (только стандартная библиотека Python)."""
+69
View File
@@ -0,0 +1,69 @@
"""Запуск: python -m splitapi (настройки из переменных окружения, см. docs/DEPLOY.md)."""
from __future__ import annotations
import logging
import os
import signal
import sys
import threading
import urllib.request
from . import security
from .config import ConfigError, from_env, is_local_host
from .http_api import make_server
from .mailer import AsyncMailer, make_mailer
from .ratelimit import RateLimiter
from .service import Service
from .store import Store
log = logging.getLogger("splitapi")
def healthcheck() -> int:
"""Для HEALTHCHECK контейнера: в образе нет curl, поэтому проверяем сами."""
port = os.environ.get("PORT") or "8000"
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=3) as response:
return 0 if response.status == 200 else 1
except OSError:
return 1
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
if "--healthcheck" in sys.argv:
return healthcheck()
try:
cfg = from_env(os.environ)
except ConfigError as exc:
log.critical("Неверные настройки: %s", exc)
return 2
if cfg.mail_backend == "console":
log.warning("SMTP не настроен: письма (со ссылками подтверждения и сброса) будут печататься в этот лог. Только для разработки!")
elif cfg.smtp_security == "none" and not is_local_host(cfg.smtp_host):
log.warning("SMTP_SECURITY=none: письма и пароль SMTP уходят на %s без шифрования. Используйте starttls или ssl.", cfg.smtp_host)
security.verify_dummy("прогрев") # первый вход с несуществующей почтой не должен выдавать себя лишней задержкой
mailer = make_mailer(cfg)
service = Service(cfg, Store(cfg.db_path), mailer, RateLimiter())
server = make_server(cfg, service)
def stop(signum: int, _frame: object) -> None:
log.info("получен сигнал %s, останавливаюсь", signum)
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
log.info("API слушает %s:%s, сайт: %s", cfg.bind, cfg.port, cfg.public_url)
try:
server.serve_forever()
finally:
server.server_close()
if isinstance(mailer, AsyncMailer):
mailer.flush(timeout=5)
mailer.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
"""Настройки сервиса из переменных окружения."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping
from urllib.parse import urlsplit
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
_SMTP_PORTS = {"starttls": 587, "ssl": 465, "none": 25}
_DEFAULT_PORTS = {"http": 80, "https": 443}
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
class ConfigError(ValueError):
"""Настройки заданы неверно: сервис не должен стартовать."""
@dataclass(frozen=True)
class Config:
public_url: str # внешний адрес сайта: из него строятся ссылки в письмах и проверяется Origin
db_path: str = "/data/splitapi.sqlite3"
bind: str = "127.0.0.1"
port: int = 8000
mail_backend: str = "console" # console | smtp
mail_from: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_security: str = "starttls" # starttls | ssl | none
smtp_user: str = ""
smtp_password: str = ""
allow_registration: bool = True
trust_proxy: bool = False # доверять X-Real-IP (его выставляет только наш nginx)
session_days: int = 30
verify_ttl: int = 24 * 3600
reset_ttl: int = 3600
@property
def _parts(self):
return urlsplit(self.public_url)
@property
def origin(self) -> str:
return f"{self._parts.scheme}://{self._parts.netloc}"
@property
def base_url(self) -> str:
return self.public_url.rstrip("/") + "/"
@property
def secure_cookie(self) -> bool:
return self._parts.scheme == "https"
@property
def cookie_name(self) -> str:
# Префикс __Host- (только при https): браузер не примет такую cookie с соседнего поддомена и без Secure
return "__Host-session" if self.secure_cookie else "session"
@property
def session_seconds(self) -> int:
return self.session_days * 86400
def _bool(env: Mapping[str, str], key: str, default: bool) -> bool:
raw = (env.get(key) or "").strip().lower()
if not raw:
return default
if raw in _TRUE:
return True
if raw in _FALSE:
return False
raise ConfigError(f"{key}: ожидалось true/false, получено {raw!r}")
def _int(env: Mapping[str, str], key: str, default: int, low: int, high: int) -> int:
raw = (env.get(key) or "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
raise ConfigError(f"{key}: ожидалось целое число") from None
if not low <= value <= high:
raise ConfigError(f"{key}: допустимы значения от {low} до {high}")
return value
def _public_url(raw: str) -> str:
"""Приводит адрес к виду, в котором браузер присылает Origin: схема и хост в нижнем регистре, без порта по умолчанию."""
parts = urlsplit(raw.strip())
if parts.scheme not in ("http", "https") or not parts.hostname or parts.query or parts.fragment or parts.username or parts.password:
raise ConfigError("PUBLIC_URL: нужен адрес вида https://example.com или https://example.com/путь, без ?, # и имени пользователя")
try:
port = parts.port
except ValueError:
raise ConfigError("PUBLIC_URL: некорректный порт") from None
try:
hostname = parts.hostname.encode("idna").decode("ascii") # браузер присылает Origin с punycode
except UnicodeError:
raise ConfigError("PUBLIC_URL: некорректное имя домена") from None
host = f"[{hostname}]" if ":" in hostname else hostname
netloc = host if port in (None, _DEFAULT_PORTS[parts.scheme]) else f"{host}:{port}"
return f"{parts.scheme}://{netloc}{parts.path.rstrip('/')}"
def is_local_host(hostname: str) -> bool:
return hostname in _LOCAL_HOSTS or hostname.endswith(".localhost")
def _mail_backend(env: Mapping[str, str], smtp_host: str, hostname: str) -> str:
explicit = (env.get("MAIL_BACKEND") or "").strip().lower()
if explicit:
if explicit not in ("console", "smtp"):
raise ConfigError("MAIL_BACKEND: допустимы console или smtp")
if explicit == "smtp" and not smtp_host:
raise ConfigError("MAIL_BACKEND=smtp требует SMTP_HOST")
return explicit
if smtp_host:
return "smtp"
if is_local_host(hostname):
return "console" # локальная проверка: письма печатаются в лог
raise ConfigError(
"SMTP не настроен: задайте SMTP_HOST (и SMTP_USER/SMTP_PASSWORD) либо явно MAIL_BACKEND=console. "
"Режим console только для разработки: ссылки подтверждения и сброса пароля тогда печатаются в лог, "
"и любой, кто читает логи, может захватить аккаунт."
)
def _smtp_password(env: Mapping[str, str]) -> str:
path = (env.get("SMTP_PASSWORD_FILE") or "").strip()
if not path:
return env.get("SMTP_PASSWORD", "")
try:
with open(path, encoding="utf-8") as handle:
return handle.read().rstrip("\r\n")
except OSError as exc:
raise ConfigError(f"SMTP_PASSWORD_FILE: не удалось прочитать файл ({exc.strerror})") from None
def from_env(env: Mapping[str, str]) -> Config:
public_url = _public_url(env.get("PUBLIC_URL") or "")
hostname = urlsplit(public_url).hostname or "localhost"
smtp_host = (env.get("SMTP_HOST") or "").strip()
backend = _mail_backend(env, smtp_host, hostname)
security = (env.get("SMTP_SECURITY") or "starttls").strip().lower()
if security not in _SMTP_PORTS:
raise ConfigError("SMTP_SECURITY: допустимы starttls, ssl или none")
return Config(
public_url=public_url,
db_path=env.get("DB_PATH") or "/data/splitapi.sqlite3",
bind=env.get("BIND") or "127.0.0.1",
port=_int(env, "PORT", 8000, 1, 65535),
mail_backend=backend,
mail_from=(env.get("MAIL_FROM") or "").strip() or f"Сплит-тренер <noreply@{hostname}>",
smtp_host=smtp_host,
smtp_port=_int(env, "SMTP_PORT", _SMTP_PORTS[security], 1, 65535),
smtp_security=security,
smtp_user=env.get("SMTP_USER", ""),
smtp_password=_smtp_password(env),
allow_registration=_bool(env, "ALLOW_REGISTRATION", True),
trust_proxy=_bool(env, "TRUST_PROXY", False),
session_days=_int(env, "SESSION_DAYS", 30, 1, 365),
)
+241
View File
@@ -0,0 +1,241 @@
"""HTTP-слой: маршруты /api/*, JSON, cookie сессии, защита от CSRF. Только стандартная библиотека."""
from __future__ import annotations
import ipaddress
import json
import logging
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
from . import security
from .config import Config
from .mailer import MailQueueFull
from .service import MESSAGES, ApiError, Service
log = logging.getLogger("splitapi")
access = logging.getLogger("splitapi.access")
MAX_BODY = 300_000
CSRF_HEADER = "X-Requested-With"
CSRF_VALUE = "split-trainer"
BUSY_RETRY_SECONDS = 5
# путь -> {метод: имя обработчика}
ROUTES: dict[str, dict[str, str]] = {
"/api/health": {"GET": "health"},
"/api/register": {"POST": "register"},
"/api/verify": {"POST": "verify"},
"/api/login": {"POST": "login"},
"/api/logout": {"POST": "logout"},
"/api/me": {"GET": "me"},
"/api/forgot": {"POST": "forgot"},
"/api/reset": {"POST": "reset"},
"/api/delete-account": {"POST": "delete_account"},
"/api/state": {"GET": "get_state", "PUT": "put_state"},
}
# Откуда принимаем X-Real-IP: тот же список, что в set_real_ip_from у nginx (частные сети и localhost)
_TRUSTED_PEERS = tuple(
ipaddress.ip_network(net)
for net in ("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "fc00::/7", "fe80::/10")
)
def _parse_ip(raw: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
try:
ip = ipaddress.ip_address(raw)
except ValueError:
return None
return ip.ipv4_mapped if ip.version == 6 and ip.ipv4_mapped else ip
def is_trusted_proxy_peer(address: str) -> bool:
ip = _parse_ip(address)
return ip is not None and any(ip in net for net in _TRUSTED_PEERS)
def normalize_ip(raw: str) -> str | None:
"""IPv4 как есть; IPv6 сводим к сети /64: у одного абонента их миллиарды, поэтому счётчик общий."""
ip = _parse_ip(raw)
if ip is None:
return None
if ip.version == 6:
return str(ipaddress.ip_network(f"{ip}/64", strict=False))
return str(ip)
def _forbid_constant(name: str) -> None:
raise ValueError(f"недопустимая константа {name}")
def make_handler(cfg: Config, service: Service) -> type[BaseHTTPRequestHandler]:
class Handler(BaseHTTPRequestHandler):
server_version = "splitapi"
sys_version = ""
timeout = 10 # защита от медленных соединений (основную работу делает nginx)
# --- вспомогательное ---
def log_message(self, format: str, *args: object) -> None: # noqa: A002 - сигнатура базового класса
pass
def client_ip(self) -> str:
peer = self.client_address[0]
chosen = peer
if cfg.trust_proxy and is_trusted_proxy_peer(peer): # заголовку верим только от своего прокси
candidate = (self.headers.get("X-Real-IP") or "").strip()
if normalize_ip(candidate):
chosen = candidate
return normalize_ip(chosen) or peer
def session_token(self) -> str | None:
"""Токен из cookie. Если cookie с таким именем несколько, не верим ни одной (подброс cookie с поддомена)."""
values = []
for part in (self.headers.get("Cookie") or "").split(";"):
name, _, value = part.strip().partition("=")
if name == cfg.cookie_name:
values.append(value.strip())
return values[0] if len(values) == 1 else None
def cookie_header(self, token: str, max_age: int) -> str:
parts = [f"{cfg.cookie_name}={token}", f"Max-Age={max_age}", "Path=/", "HttpOnly", "SameSite=Lax"]
if cfg.secure_cookie:
parts.append("Secure")
return "; ".join(parts)
def send_json(self, status: int, body: dict, headers: dict[str, str] | None = None, cookies: list[str] | None = None) -> None:
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
try:
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
for name, value in (headers or {}).items():
self.send_header(name, value)
for cookie in cookies or []:
self.send_header("Set-Cookie", cookie)
self.end_headers()
self.wfile.write(payload)
except OSError:
self.close_connection = True # клиент ушёл, не дождавшись ответа: это не ошибка сервера
return
access.info("%s %s %r -> %s", self.client_ip(), self.command, urlsplit(self.path).path, status)
def error(self, err: ApiError) -> None:
headers = {"Retry-After": str(err.retry_after)} if err.retry_after else None
self.send_json(err.status, {"error": err.code, "message": err.message, **err.extra}, headers)
# --- проверки запроса ---
def check_csrf(self) -> None:
origin = self.headers.get("Origin")
if origin is not None and origin != cfg.origin:
log.warning("запрос отклонён: Origin %r, ожидался %r (проверьте PUBLIC_URL)", origin, cfg.origin)
raise ApiError(403, "csrf")
if self.headers.get(CSRF_HEADER) != CSRF_VALUE:
raise ApiError(403, "csrf")
def read_json(self) -> dict:
content_type = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
if content_type != "application/json":
raise ApiError(415, "unsupported_media_type")
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
raise ApiError(400, "invalid_json") from None
if length < 0:
raise ApiError(400, "invalid_json")
if length > MAX_BODY:
raise ApiError(413, "payload_too_large")
raw = self.rfile.read(length) if length else b""
try:
data = json.loads(raw.decode("utf-8"), parse_constant=_forbid_constant)
except (ValueError, RecursionError):
raise ApiError(400, "invalid_json") from None
if not isinstance(data, dict):
raise ApiError(400, "invalid_json")
return data
def require_user(self) -> dict:
user = service.me(self.session_token())
if user is None:
raise ApiError(401, "unauthorized")
return user
# --- обработчики маршрутов ---
def h_health(self, body: dict | None) -> None:
self.send_json(200, {"ok": True})
def h_register(self, body: dict) -> None:
self.send_json(200, service.register(body.get("email"), self.client_ip()))
def h_verify(self, body: dict) -> None:
self.send_json(200, service.verify(body.get("token"), body.get("password"), self.client_ip()))
def h_login(self, body: dict) -> None:
token, email = service.login(body.get("email"), body.get("password"), self.client_ip())
self.send_json(200, {"ok": True, "email": email}, cookies=[self.cookie_header(token, cfg.session_seconds)])
def h_logout(self, body: dict) -> None:
service.logout(self.session_token())
self.send_json(200, {"ok": True}, cookies=[self.cookie_header("", 0)])
def h_me(self, body: dict | None) -> None:
# Гостю тоже 200 (email: null): иначе каждый заход анонима оставляет «ошибку» в консоли браузера
user = service.me(self.session_token())
self.send_json(200, {"email": user["email"] if user else None})
def h_forgot(self, body: dict) -> None:
self.send_json(200, service.forgot(body.get("email"), self.client_ip()))
def h_reset(self, body: dict) -> None:
self.send_json(200, service.reset(body.get("token"), body.get("password"), self.client_ip()))
def h_delete_account(self, body: dict) -> None:
result = service.delete_account(self.session_token(), body.get("password"))
self.send_json(200, result, cookies=[self.cookie_header("", 0)])
def h_get_state(self, body: dict | None) -> None:
user = self.require_user()
self.send_json(200, {**service.get_state(user["id"]), "email": user["email"]})
def h_put_state(self, body: dict) -> None:
user = self.require_user()
self.send_json(200, service.put_state(user["id"], body.get("version"), body.get("data"), user["email"], body.get("email")))
# --- маршрутизация ---
def dispatch(self) -> None:
self.close_connection = True
path = urlsplit(self.path).path
try:
methods = ROUTES.get(path)
if methods is None:
raise ApiError(404, "not_found")
if self.command not in methods:
self.send_json(405, {"error": "method_not_allowed", "message": MESSAGES["method_not_allowed"]},
{"Allow": ", ".join(methods)})
return
body = None
if self.command != "GET":
self.check_csrf()
body = self.read_json()
getattr(self, "h_" + methods[self.command])(body)
except ApiError as err:
self.error(err)
except (security.HashBusy, MailQueueFull):
self.error(ApiError(503, "busy", retry_after=BUSY_RETRY_SECONDS))
except (TimeoutError, ConnectionError):
log.debug("соединение оборвано клиентом: %s %r", self.command, path)
except Exception:
log.exception("необработанная ошибка в %s %r", self.command, path)
self.send_json(500, {"error": "server_error", "message": MESSAGES["server_error"]})
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = dispatch
return Handler
def make_server(cfg: Config, service: Service, host: str | None = None, port: int | None = None) -> ThreadingHTTPServer:
server = ThreadingHTTPServer((host or cfg.bind, cfg.port if port is None else port), make_handler(cfg, service))
server.daemon_threads = True # не держим процесс из-за зависших соединений при остановке
return server
+188
View File
@@ -0,0 +1,188 @@
"""Отправка писем: SMTP, вывод в лог (для разработки), память (для тестов), фоновая очередь."""
from __future__ import annotations
import logging
import queue
import smtplib
import ssl
import threading
import time
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formatdate, make_msgid, parseaddr
from typing import Protocol
from .config import Config
log = logging.getLogger("splitapi.mail")
@dataclass(frozen=True)
class Mail:
to: str
subject: str
body: str
class Mailer(Protocol):
def send(self, mail: Mail) -> None: ...
class MailQueueFull(RuntimeError):
"""Очередь отправки переполнена: письмо не принято (API ответит «сервер занят», а не «проверьте почту»)."""
def _plural(n: int, one: str, few: str, many: str) -> str:
if 11 <= n % 100 <= 14:
return many
return one if n % 10 == 1 else few if 2 <= n % 10 <= 4 else many
def human_duration(seconds: int) -> str:
"""«24 часа», «3 дня», «30 минут»: срок действия ссылки в тексте письма берётся из настроек."""
if seconds >= 2 * 86400 and seconds % 86400 == 0:
n = seconds // 86400
return f"{n} {_plural(n, 'день', 'дня', 'дней')}"
if seconds >= 3600 and seconds % 3600 == 0:
n = seconds // 3600
return f"{n} {_plural(n, 'час', 'часа', 'часов')}"
n = max(1, round(seconds / 60))
return f"{n} {_plural(n, 'минута', 'минуты', 'минут')}"
# --- шаблоны писем ---
def verify_mail(cfg: Config, to: str, token: str) -> Mail:
# Токен во фрагменте (#): он не уходит на сервер, не попадает в логи и заголовок Referer.
link = f"{cfg.base_url}#verify={token}"
return Mail(
to,
"Подтвердите почту в Сплит-тренере",
"Здравствуйте!\n\nЧтобы завершить регистрацию в Сплит-тренере, подтвердите адрес почты и задайте пароль. "
f"Откройте ссылку:\n\n{link}\n\nСсылка действует {human_duration(cfg.verify_ttl)} и работает один раз.\n\n"
"Если вы не регистрировались, просто проигнорируйте это письмо: без перехода по ссылке учётная запись не создаётся.\n",
)
def reset_mail(cfg: Config, to: str, token: str) -> Mail:
link = f"{cfg.base_url}#reset={token}"
return Mail(
to,
"Сброс пароля в Сплит-тренере",
"Здравствуйте!\n\nМы получили запрос на сброс пароля. Чтобы задать новый пароль, откройте ссылку:\n\n"
f"{link}\n\nСсылка действует {human_duration(cfg.reset_ttl)} и работает один раз.\n\n"
"Если вы не запрашивали сброс, проигнорируйте это письмо: пароль останется прежним.\n",
)
def already_registered_mail(cfg: Config, to: str) -> Mail:
return Mail(
to,
"Вы уже зарегистрированы в Сплит-тренере",
"Здравствуйте!\n\nКто-то (возможно, вы) пытался зарегистрировать этот адрес, но он уже есть в системе.\n\n"
f"Если это были вы и вы забыли пароль, на странице входа нажмите «Забыли пароль?»: {cfg.base_url}\n\n"
"Если это были не вы, ничего делать не нужно.\n",
)
def password_changed_mail(cfg: Config, to: str) -> Mail:
return Mail(
to,
"Пароль в Сплит-тренере изменён",
"Здравствуйте!\n\nПароль вашей учётной записи изменён, все прежние входы завершены.\n\n"
f"Если это были не вы, срочно запросите сброс пароля: {cfg.base_url}\n",
)
def build_message(mail: Mail, from_addr: str) -> EmailMessage:
"""Собирает письмо. Переводы строк в заголовках запрещены самим EmailMessage (ValueError): инъекция невозможна."""
msg = EmailMessage()
msg["From"] = from_addr
msg["To"] = mail.to
msg["Subject"] = mail.subject
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid(domain=parseaddr(from_addr)[1].rpartition("@")[2] or "localhost")
msg.set_content(mail.body)
return msg
# --- способы доставки ---
class MemoryMailer:
def __init__(self) -> None:
self.sent: list[Mail] = []
def send(self, mail: Mail) -> None:
self.sent.append(mail)
class ConsoleMailer:
"""Только для разработки: письмо с ссылкой попадает в лог. Не используйте на боевом сервере."""
def send(self, mail: Mail) -> None:
log.warning("[DEV: SMTP не настроен] письмо для %s\nТема: %s\n%s", mail.to, mail.subject, mail.body)
class SmtpMailer:
def __init__(self, cfg: Config) -> None:
self._cfg = cfg
def send(self, mail: Mail) -> None:
cfg = self._cfg
msg = build_message(mail, cfg.mail_from)
if cfg.smtp_security == "ssl":
client = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=15, context=ssl.create_default_context())
else:
client = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=15)
with client as server:
if cfg.smtp_security == "starttls":
server.starttls(context=ssl.create_default_context())
if cfg.smtp_user:
server.login(cfg.smtp_user, cfg.smtp_password)
server.send_message(msg)
class AsyncMailer:
"""Отправка в фоне: ответ API не ждёт SMTP, а время ответа не выдаёт, существует ли адрес."""
def __init__(self, inner: Mailer, maxsize: int = 100) -> None:
self._inner = inner
self._queue: queue.Queue[Mail | None] = queue.Queue(maxsize=maxsize)
self._thread = threading.Thread(target=self._run, name="mailer", daemon=True)
self._thread.start()
def send(self, mail: Mail) -> None:
try:
self._queue.put_nowait(mail)
except queue.Full:
log.error("очередь писем переполнена, письмо не принято (домен получателя: %s)", mail.to.rpartition("@")[2])
raise MailQueueFull from None
def _run(self) -> None:
while True:
mail = self._queue.get()
try:
if mail is None:
return
self._inner.send(mail)
except Exception as exc: # одно неудачное письмо не должно останавливать отправку остальных
# Текст исключения не пишем: в нём бывает адрес получателя (например, при отказе SMTP)
log.error("не удалось отправить письмо (домен получателя: %s): %s", mail.to.rpartition("@")[2], type(exc).__name__)
finally:
self._queue.task_done()
def flush(self, timeout: float = 10) -> None:
deadline = time.monotonic() + timeout
while self._queue.unfinished_tasks and time.monotonic() < deadline:
time.sleep(0.01)
def close(self) -> None:
try:
self._queue.put(None, timeout=1)
except queue.Full:
return
self._thread.join(timeout=5)
def make_mailer(cfg: Config) -> Mailer:
if cfg.mail_backend == "smtp":
return AsyncMailer(SmtpMailer(cfg))
return ConsoleMailer()
+79
View File
@@ -0,0 +1,79 @@
"""Ограничитель частоты запросов со скользящим окном (в памяти одного процесса)."""
from __future__ import annotations
import math
import threading
import time
from collections import OrderedDict, deque
from typing import Callable
class RateLimiter:
def __init__(self, clock: Callable[[], float] = time.monotonic, max_keys: int = 50_000, purge_interval: int = 60) -> None:
self._clock = clock
self._max_keys = max_keys
self._purge_interval = purge_interval
self._last_purge = float("-inf")
self._lock = threading.Lock()
self._hits: OrderedDict[str, tuple[deque[float], int]] = OrderedDict()
def check(self, key: str, limit: int, window: int) -> tuple[bool, int]:
"""Учитывает попытку. Возвращает (разрешено, через сколько секунд повторить)."""
now = self._clock()
with self._lock:
entry = self._hits.get(key)
hits = entry[0] if entry else deque()
while hits and hits[0] <= now - window:
hits.popleft()
if len(hits) >= limit:
self._hits[key] = (hits, window)
return False, max(1, math.ceil(hits[0] + window - now))
hits.append(now)
self._hits[key] = (hits, window)
self._hits.move_to_end(key)
self._evict(now)
return True, 0
def is_blocked(self, key: str, limit: int, window: int) -> tuple[bool, int]:
"""Проверяет лимит, не засчитывая попытку (вместе с record() считает только неудачи)."""
now = self._clock()
with self._lock:
entry = self._hits.get(key)
hits = entry[0] if entry else deque()
while hits and hits[0] <= now - window:
hits.popleft()
if len(hits) >= limit:
return True, max(1, math.ceil(hits[0] + window - now))
return False, 0
def record(self, key: str, window: int) -> None:
now = self._clock()
with self._lock:
entry = self._hits.get(key)
hits = entry[0] if entry else deque()
hits.append(now)
self._hits[key] = (hits, window)
self._hits.move_to_end(key)
self._evict(now)
def forgive(self, key: str) -> None:
"""Возвращает последнюю учтённую попытку (успешный вход не должен приближать блокировку)."""
with self._lock:
entry = self._hits.get(key)
if entry and entry[0]:
entry[0].pop()
def _evict(self, now: float) -> None:
if len(self._hits) <= self._max_keys:
return
# Полный проход по ключам дорог, поэтому не чаще раза в purge_interval; иначе просто вытесняем самые старые.
if now - self._last_purge >= self._purge_interval:
self._last_purge = now
for key in [k for k, (hits, window) in self._hits.items() if not hits or hits[-1] <= now - window]:
del self._hits[key]
while len(self._hits) > self._max_keys:
self._hits.popitem(last=False)
def size(self) -> int:
with self._lock:
return len(self._hits)
+164
View File
@@ -0,0 +1,164 @@
"""Примитивы безопасности: проверка почты и паролей, хеширование (scrypt), токены."""
from __future__ import annotations
import base64
import hashlib
import hmac
import re
import secrets
import threading
import unicodedata
# Параметры scrypt (OWASP допускает N=2^15, r=8, p=1 как разумный минимум для небольшого сервера).
SCRYPT_N = 2**15
SCRYPT_R = 8
SCRYPT_P = 1
_DKLEN = 32
_SALT_BYTES = 16
_MAX_MEM = 256 * 1024 * 1024
PASSWORD_MIN = 8
PASSWORD_MAX = 128 # верхняя граница ограничивает стоимость хеширования для атакующего
_EMAIL_MAX = 254
# Не больше нескольких одновременных хешей: каждый занимает десятки МБ памяти.
SLOT_WAIT = 5.0 # сколько секунд ждать свободный слот, прежде чем ответить «сервер занят»
_slots = threading.BoundedSemaphore(4)
class HashBusy(RuntimeError):
"""Все слоты хеширования заняты: лучше быстро отказать, чем копить зависшие запросы."""
# fullmatch, а не match с $: $ срабатывает и перед завершающим переводом строки.
# В адресе допустимы только безопасные символы: % | / ! # $ & * = ? ^ ` { } ~ формально валидны по RFC,
# но опасны для старых почтовых систем (переадресация через %, выполнение через |).
_LOCAL_RE = re.compile(r"[a-z0-9._+'-]{1,64}")
_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{16,100}")
_COMMON_PASSWORDS = frozenset(
{
"password", "password1", "password123", "12345678", "123456789", "1234567890", "12341234",
"00000000", "11111111", "qwertyui", "qwertyuiop", "qwerty123", "iloveyou", "admin123",
"letmein1", "abc12345", "пароль123", "йцукенгш", "qazwsxedc", "1q2w3e4r",
}
)
def normalize_email(raw: object) -> str | None:
"""Приводит адрес к каноническому виду (нижний регистр, домен в punycode) или возвращает None."""
if not isinstance(raw, str):
return None
email = raw.strip().lower()
if not email or len(email) > _EMAIL_MAX or email.count("@") != 1:
return None
local, domain = email.split("@")
if not _LOCAL_RE.fullmatch(local) or local.startswith(".") or local.endswith(".") or ".." in local:
return None
try:
ascii_domain = domain.encode("idna").decode("ascii")
except UnicodeError:
return None
labels = ascii_domain.split(".")
if len(ascii_domain) > 253 or len(labels) < 2 or len(labels[-1]) < 2:
return None
if not all(_LABEL_RE.fullmatch(label) for label in labels):
return None
return f"{local}@{ascii_domain}"
def validate_password(password: object, email: str) -> str | None:
"""Возвращает код ошибки или None, если пароль допустим."""
if not isinstance(password, str) or len(password) < PASSWORD_MIN:
return "password_too_short"
if len(password) > PASSWORD_MAX:
return "password_too_long"
try:
password.encode("utf-8")
except UnicodeEncodeError:
return "password_invalid" # одиночный суррогат: json его пропускает, а хешер упал бы
lowered = password.lower()
local = email.split("@")[0].lower()
if lowered in (email.lower(), local) or lowered in _COMMON_PASSWORDS:
return "password_weak"
return None
def _prepare(password: str) -> bytes:
return unicodedata.normalize("NFKC", password).encode("utf-8")
def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
if not _slots.acquire(timeout=SLOT_WAIT):
raise HashBusy("слишком много одновременных проверок пароля")
try:
return hashlib.scrypt(_prepare(password), salt=salt, n=n, r=r, p=p, dklen=_DKLEN, maxmem=_MAX_MEM)
finally:
_slots.release()
def hash_password(password: str, n: int | None = None) -> str:
"""Формат: scrypt$N$r$p$соль$хеш (параметры внутри позволяют позже усилить хеширование)."""
n = n or SCRYPT_N
salt = secrets.token_bytes(_SALT_BYTES)
digest = _derive(password, salt, n, SCRYPT_R, SCRYPT_P)
return "$".join(
["scrypt", str(n), str(SCRYPT_R), str(SCRYPT_P), base64.b64encode(salt).decode(), base64.b64encode(digest).decode()]
)
def _parse(stored: str) -> tuple[int, int, int, bytes, bytes] | None:
try:
scheme, n, r, p, salt, digest = stored.split("$")
if scheme != "scrypt":
return None
n_i, r_i, p_i = int(n), int(r), int(p)
# Защита от подменённых параметров: не даём заставить сервер считать гигабайтный хеш
if not (2**10 <= n_i <= 2**20 and n_i & (n_i - 1) == 0 and 1 <= r_i <= 16 and 1 <= p_i <= 4):
return None
return n_i, r_i, p_i, base64.b64decode(salt, validate=True), base64.b64decode(digest, validate=True)
except (ValueError, TypeError):
return None
def verify_password(stored: str, password: str) -> bool:
parsed = _parse(stored) if isinstance(stored, str) else None
if parsed is None:
return False
n, r, p, salt, expected = parsed
try:
actual = _derive(password, salt, n, r, p)
except (ValueError, MemoryError):
return False
return hmac.compare_digest(actual, expected)
def needs_rehash(stored: str) -> bool:
parsed = _parse(stored)
return parsed is None or parsed[:3] != (SCRYPT_N, SCRYPT_R, SCRYPT_P)
_dummy_lock = threading.Lock()
_dummy_hash: str | None = None
def verify_dummy(password: str) -> bool:
"""Тратит столько же времени, как проверка настоящего пароля: ответ на вход не выдаёт, есть ли такая почта."""
global _dummy_hash
with _dummy_lock:
if _dummy_hash is None:
_dummy_hash = hash_password(secrets.token_urlsafe(16))
verify_password(_dummy_hash, password)
return False
def new_token() -> tuple[str, str]:
"""Возвращает (токен для пользователя, его SHA-256 для базы): в базе токены в открытом виде не хранятся."""
raw = secrets.token_urlsafe(32)
return raw, hashlib.sha256(raw.encode("ascii")).hexdigest()
def hash_token(raw: object) -> str | None:
if not isinstance(raw, str) or not _TOKEN_RE.fullmatch(raw):
return None
return hashlib.sha256(raw.encode("ascii")).hexdigest()
+277
View File
@@ -0,0 +1,277 @@
"""Бизнес-логика: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта, хранение прогресса.
Принципы: ответы для существующих и несуществующих адресов одинаковы, токены одноразовые и хранятся только
в виде хеша, пароль хешируется scrypt и задаётся только после подтверждения почты, частота попыток ограничена.
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from typing import Callable
from . import mailer as templates
from . import security
from .config import Config
from .mailer import MailQueueFull, Mailer
from .ratelimit import RateLimiter
from .statevalid import StateError, clean_state
from .store import Store
log = logging.getLogger("splitapi")
MAX_STATE_BYTES = 300_000
PASSWORD_INPUT_MAX = 1024
PURGE_INTERVAL = 300 # как часто чистить просроченные записи (секунды), а не на каждом запросе
# Ограничения частоты: имя -> (сколько попыток, за сколько секунд)
LIMITS: dict[str, tuple[int, int]] = {
"register_ip": (5, 3600),
"register_email": (3, 3600), # молча: письмо просто не отправляется
"verify_ip": (20, 600),
"login_ip": (20, 600), # считаются только неудачные входы (успешный вход возвращает попытку)
"login_pair": (5, 600), # неудачи для пары «адрес + IP»: чужой IP не блокирует владельца
"login_email": (100, 3600), # общий потолок на аккаунт против распределённого подбора (с запасом для владельца)
"forgot_ip": (5, 3600),
"forgot_email": (3, 3600), # молча
"reset_ip": (10, 600),
"delete_user": (5, 600),
"state_user": (60, 60),
}
MESSAGES = {
"invalid_email": "Введите корректный адрес почты.",
"password_too_short": "Пароль должен быть не короче 8 символов.",
"password_too_long": "Пароль слишком длинный: не более 128 символов.",
"password_weak": "Пароль слишком простой: выберите другой.",
"password_invalid": "Пароль содержит недопустимые символы.",
"rate_limited": "Слишком много попыток. Повторите позже.",
"busy": "Сервер сейчас занят. Повторите через несколько секунд.",
"registration_closed": "Регистрация на этом сервере закрыта.",
"invalid_credentials": "Неверная почта или пароль. Если вы только зарегистрировались, подтвердите почту по ссылке из письма.",
"wrong_password": "Неверный пароль.",
"invalid_token": "Ссылка недействительна или устарела. Запросите новую.",
"unauthorized": "Нужно войти в аккаунт.",
"invalid_state": "Некорректные данные прогресса.",
"version_conflict": "На сервере более новая версия прогресса.",
"account_mismatch": "В другой вкладке выполнен вход в другой аккаунт. Страница будет обновлена.",
"csrf": "Запрос отклонён.",
"invalid_json": "Некорректный запрос.",
"payload_too_large": "Слишком большой запрос.",
"unsupported_media_type": "Ожидался JSON.",
"not_found": "Не найдено.",
"method_not_allowed": "Метод не поддерживается.",
"server_error": "Внутренняя ошибка сервера.",
}
REGISTER_OK = {"ok": True, "message": "Проверьте почту: мы отправили письмо со ссылкой. По ней вы подтвердите адрес и задайте пароль. Если письма нет, загляните в «Спам»."}
FORGOT_OK = {"ok": True, "message": "Если такой адрес зарегистрирован, мы отправили на него письмо со ссылкой для сброса пароля."}
VERIFY_OK = {"ok": True, "message": "Аккаунт создан. Теперь можно войти."}
RESET_OK = {"ok": True, "message": "Пароль изменён. Войдите с новым паролем."}
DELETE_OK = {"ok": True, "message": "Аккаунт и прогресс на сервере удалены."}
class ApiError(Exception):
def __init__(self, status: int, code: str, retry_after: int | None = None, extra: dict | None = None) -> None:
super().__init__(code)
self.status = status
self.code = code
self.message = MESSAGES.get(code, code)
self.retry_after = retry_after
self.extra = extra or {}
def _key(name: str, value: str) -> str:
return f"{name}:{hashlib.sha256(value.encode('utf-8', 'replace')).hexdigest()[:16]}"
class Service:
def __init__(self, cfg: Config, store: Store, mailer: Mailer, limiter: RateLimiter, clock: Callable[[], float] = time.time) -> None:
self._cfg = cfg
self._store = store
self._mailer = mailer
self._limiter = limiter
self._clock = clock
self._last_purge = 0.0
# --- ограничения и обслуживание ---
def _reserve(self, *items: tuple[str, str]) -> list[str]:
"""Занимает попытку во всех счётчиках ДО дорогой проверки пароля (иначе параллельные запросы обходят лимит).
Успех возвращает попытки через _release, поэтому в счётчиках остаются только неудачи."""
taken: list[str] = []
for name, ident in items:
key = _key(name, ident)
allowed, retry = self._limiter.check(key, *LIMITS[name])
if not allowed:
self._release(taken)
raise ApiError(429, "rate_limited", retry_after=retry)
taken.append(key)
return taken
def _release(self, keys: list[str]) -> None:
for key in keys:
self._limiter.forgive(key)
def _quiet_limit(self, name: str, ident: str) -> bool:
"""Лимит, о котором пользователь не узнаёт (иначе он раскрывал бы сведения об адресе)."""
return self._limiter.check(_key(name, ident), *LIMITS[name])[0]
def _maybe_purge(self, now: float) -> None:
if now - self._last_purge >= PURGE_INTERVAL:
self._last_purge = now
self._store.purge_expired(now)
# --- регистрация ---
def register(self, email_raw: object, ip: str) -> dict:
"""Принимает только адрес: пароль человек задаёт по ссылке из письма, поэтому чужая регистрация ничего не подменит."""
if not self._cfg.allow_registration:
raise ApiError(403, "registration_closed")
self._reserve(("register_ip", ip))
email = security.normalize_email(email_raw)
if email is None:
raise ApiError(400, "invalid_email")
now = self._clock()
self._maybe_purge(now)
if not self._quiet_limit("register_email", email):
return REGISTER_OK
if self._store.user_by_email(email) is not None:
self._mailer.send(templates.already_registered_mail(self._cfg, email))
else:
raw, token_hash = security.new_token()
self._store.save_registration(email, token_hash, now + self._cfg.verify_ttl, now)
self._mailer.send(templates.verify_mail(self._cfg, email, raw))
return REGISTER_OK
def verify(self, token_raw: object, password: object, ip: str) -> dict:
"""Подтверждение почты и выбор пароля: учётная запись создаётся только здесь."""
self._reserve(("verify_ip", ip))
token_hash = security.hash_token(token_raw)
now = self._clock()
email = self._store.peek_registration(token_hash, now) if token_hash else None
if email is None:
raise ApiError(400, "invalid_token")
problem = security.validate_password(password, email)
if problem:
raise ApiError(400, problem) # ссылка не сгорает: можно выбрать другой пароль
pw_hash = security.hash_password(password)
if not self._store.activate_registration(token_hash, pw_hash, now):
raise ApiError(400, "invalid_token")
return VERIFY_OK
# --- вход и выход ---
def login(self, email_raw: object, password: object, ip: str) -> tuple[str, str]:
email = security.normalize_email(email_raw)
ident = email or str(email_raw)[:254].lower()
reserved = self._reserve(("login_ip", ip), ("login_pair", f"{ip}|{ident}"), ("login_email", ident))
if not isinstance(password, str) or len(password) > PASSWORD_INPUT_MAX:
password = ""
user = self._store.user_by_email(email) if email else None
valid = security.verify_password(user["pw_hash"], password) if user is not None else security.verify_dummy(password)
if not valid:
raise ApiError(401, "invalid_credentials")
current_hash = user["pw_hash"]
if security.needs_rehash(current_hash):
upgraded = security.hash_password(password)
# Условная запись: если пароль успели сменить (сброс), старый пароль не возвращается, а вход отклоняется ниже
if self._store.set_password(user["id"], upgraded, expect_pw_hash=current_hash):
current_hash = upgraded
now = self._clock()
raw, token_hash = security.new_token()
if not self._store.create_session(user["id"], token_hash, now + self._cfg.session_seconds, now, expect_pw_hash=current_hash):
raise ApiError(401, "invalid_credentials") # пароль сменили, пока шла проверка
self._release(reserved)
return raw, user["email"]
def logout(self, session_raw: object) -> None:
token_hash = security.hash_token(session_raw)
if token_hash:
self._store.delete_session(token_hash)
def me(self, session_raw: object) -> dict | None:
token_hash = security.hash_token(session_raw)
row = self._store.session_user(token_hash, self._clock()) if token_hash else None
return {"id": row["id"], "email": row["email"]} if row else None
def delete_account(self, session_raw: object, password: object) -> dict:
"""Удаляет аккаунт, сессии и прогресс. Нужен пароль: украденная cookie не должна стирать чужие данные."""
user = self.me(session_raw)
if user is None:
raise ApiError(401, "unauthorized")
reserved = self._reserve(("delete_user", str(user["id"])))
row = self._store.user_by_email(user["email"])
if not isinstance(password, str) or len(password) > PASSWORD_INPUT_MAX:
password = ""
if row is None or not security.verify_password(row["pw_hash"], password):
raise ApiError(401, "wrong_password")
self._store.delete_user(user["id"])
self._release(reserved)
return DELETE_OK
# --- сброс пароля ---
def forgot(self, email_raw: object, ip: str) -> dict:
self._reserve(("forgot_ip", ip))
email = security.normalize_email(email_raw)
if email is None:
raise ApiError(400, "invalid_email")
now = self._clock()
self._maybe_purge(now)
if not self._quiet_limit("forgot_email", email):
return FORGOT_OK
user = self._store.user_by_email(email)
if user is not None:
raw, token_hash = security.new_token()
self._store.save_reset_token(user["id"], token_hash, now + self._cfg.reset_ttl)
try:
self._mailer.send(templates.reset_mail(self._cfg, email, raw))
except MailQueueFull:
# Ответ обязан быть тем же, что для незарегистрированного адреса, иначе по перегрузке очереди можно
# выяснить, какие адреса есть в системе. Сбой уже записан в лог почтовой очереди.
pass
return FORGOT_OK
def reset(self, token_raw: object, password: object, ip: str) -> dict:
self._reserve(("reset_ip", ip))
token_hash = security.hash_token(token_raw)
now = self._clock()
row = self._store.peek_reset_token(token_hash, now) if token_hash else None
if row is None:
raise ApiError(400, "invalid_token")
problem = security.validate_password(password, row["email"])
if problem:
raise ApiError(400, problem) # токен не сгорает: можно выбрать другой пароль
pw_hash = security.hash_password(password) # считаем до погашения токена: сбой хеширования не сжигает ссылку
result = self._store.reset_password(token_hash, pw_hash, now) # токен, пароль и сессии: одна транзакция
if result is None:
raise ApiError(400, "invalid_token")
try:
self._mailer.send(templates.password_changed_mail(self._cfg, result["email"]))
except MailQueueFull:
pass # уведомление не критично: пароль уже изменён
return RESET_OK
# --- прогресс ---
def get_state(self, user_id: int) -> dict:
row = self._store.get_state(user_id)
if row is None:
return {"version": 0, "data": clean_state({})}
return {"version": row[0], "data": json.loads(row[1])}
def put_state(self, user_id: int, version: object, data: object, account_email: str, claimed_email: object = None) -> dict:
"""claimed_email: от чьего имени клиент сохраняет данные; несовпадение значит, что вход в другой вкладке сменили."""
if claimed_email is not None and str(claimed_email).strip().lower() != account_email:
raise ApiError(409, "account_mismatch")
self._reserve(("state_user", str(user_id)))
if isinstance(version, bool) or not isinstance(version, int) or version < 0:
raise ApiError(400, "invalid_state")
try:
cleaned = clean_state(data)
except StateError:
raise ApiError(400, "invalid_state") from None
payload = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
if len(payload.encode("utf-8")) > MAX_STATE_BYTES:
raise ApiError(400, "invalid_state")
new_version = self._store.put_state(user_id, version, payload, self._clock())
if new_version is None:
raise ApiError(409, "version_conflict", extra=self.get_state(user_id))
return {"version": new_version}
+109
View File
@@ -0,0 +1,109 @@
"""Проверка и очистка состояния прогресса, которое клиент хранит на сервере.
Сервер не доверяет присланному JSON: оставляем только известные поля, приводим числа к допустимым
диапазонам и ограничиваем размеры. Так испорченные данные не попадут ни на другие устройства, ни в интерфейс.
"""
from __future__ import annotations
import math
import re
MAX_HISTORY = 600
MAX_PROGRESS = 200
MAX_KEYS = 200
_ID_MAX = 40
_ID_RE = re.compile(r"[^A-Za-z0-9_.-]")
_LANGS = ("ru", "en")
_KINDS = ("lesson", "practice", "weak")
class StateError(ValueError):
"""Состояние имеет неверную структуру."""
def _num(value: object, low: float, high: float) -> int | float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return 0
if isinstance(value, float) and not math.isfinite(value):
return 0
clamped = min(max(value, low), high)
return clamped
def _ident(value: object) -> str:
return _ID_RE.sub("", value)[:_ID_MAX] if isinstance(value, str) else ""
def _is_key_char(char: object) -> bool:
# одиночные суррогаты не кодируются в UTF-8 и ломали бы сохранение
return isinstance(char, str) and len(char) == 1 and not 0xD800 <= ord(char) <= 0xDFFF
def _choice(value: object, allowed: tuple[str, ...]) -> str:
return value if isinstance(value, str) and value in allowed else ""
def _history_row(row: dict) -> dict:
return {
"t": _num(row.get("t"), 0, 4_000_000_000_000),
"id": _ident(row.get("id")),
"k": _choice(row.get("k"), _KINDS),
"lang": _choice(row.get("lang"), _LANGS),
"cpm": _num(row.get("cpm"), 0, 3000),
"acc": _num(row.get("acc"), 0, 100),
"err": _num(row.get("err"), 0, 100_000),
"len": _num(row.get("len"), 0, 100_000),
"sec": _num(row.get("sec"), 0, 1_000_000),
}
def _progress_row(row: dict) -> dict:
return {
"best": _num(row.get("best"), 0, 3000),
"acc": _num(row.get("acc"), 0, 100),
"stars": int(_num(row.get("stars"), 0, 3)),
"tries": int(_num(row.get("tries"), 0, 100_000)),
}
def _key_row(row: dict) -> dict:
return {name: _num(row.get(name), 0, 1_000_000_000) for name in ("n", "err", "ms", "ln")}
def _section(data: dict, name: str, kind: type) -> object:
value = data.get(name)
if value is None:
return kind()
if not isinstance(value, kind):
raise StateError(f"поле {name} имеет неверный тип")
return value
def clean_state(data: object) -> dict:
"""Возвращает очищенную копию состояния или поднимает StateError."""
if not isinstance(data, dict):
raise StateError("ожидался объект")
progress_in = _section(data, "progress", dict)
history_in = _section(data, "history", list)
keys_in = _section(data, "keys", dict)
progress = {}
for key, row in sorted(progress_in.items()): # порядок как у клиента: обрезка одинакова на обеих сторонах
ident = _ident(key)
if ident and isinstance(row, dict) and len(progress) < MAX_PROGRESS:
progress[ident] = _progress_row(row)
history = [_history_row(row) for row in history_in if isinstance(row, dict)][-MAX_HISTORY:]
keys = {}
for lang in _LANGS:
per_lang = keys_in.get(lang, {})
if not isinstance(per_lang, dict):
raise StateError("поле keys имеет неверный тип")
cleaned = {}
for char, row in per_lang.items():
if _is_key_char(char) and isinstance(row, dict) and len(cleaned) < MAX_KEYS:
cleaned[char] = _key_row(row)
keys[lang] = cleaned
return {"progress": progress, "history": history, "keys": keys}
+245
View File
@@ -0,0 +1,245 @@
"""Хранилище SQLite. Все запросы параметризованы; одноразовые токены гасятся атомарно."""
from __future__ import annotations
import os
import sqlite3
from contextlib import contextmanager
from typing import Iterator
SCHEMA_VERSION = 2 # 2: регистрация больше не хранит пароль (его задают после подтверждения почты)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
pw_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- Ожидание подтверждения почты: пользователь появляется только после перехода по ссылке и выбора пароля.
CREATE TABLE IF NOT EXISTS registrations (
email TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS reset_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS registrations_expires ON registrations(expires_at);
CREATE INDEX IF NOT EXISTS reset_tokens_expires ON reset_tokens(expires_at);
CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS user_state (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
version INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
"""
_COUNTABLE = frozenset({"users", "registrations", "reset_tokens", "sessions", "user_state"})
class Store:
def __init__(self, path: str) -> None:
self._path = path
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
self._init_schema()
try:
os.chmod(path, 0o600)
except OSError:
pass # Windows и некоторые файловые системы не поддерживают права
@contextmanager
def _tx(self, immediate: bool = False) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self._path, timeout=5, isolation_level=None)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN IMMEDIATE" if immediate else "BEGIN")
try:
yield conn
except BaseException:
conn.execute("ROLLBACK")
raise
conn.execute("COMMIT")
finally:
conn.close()
def _init_schema(self) -> None:
conn = sqlite3.connect(self._path, timeout=5, isolation_level=None)
try:
conn.execute("PRAGMA journal_mode = WAL")
version = conn.execute("PRAGMA user_version").fetchone()[0]
if version < 2:
# Ожидающие регистрации одноразовы и недолговечны: старые (с паролем) просто отбрасываем
conn.execute("DROP TABLE IF EXISTS registrations")
conn.executescript(_SCHEMA)
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
finally:
conn.close()
# --- пользователи ---
def create_user(self, email: str, pw_hash: str, now: float) -> int | None:
try:
with self._tx(immediate=True) as conn:
return conn.execute(
"INSERT INTO users (email, pw_hash, created_at) VALUES (?, ?, ?)", (email, pw_hash, int(now))
).lastrowid
except sqlite3.IntegrityError:
return None
def user_by_email(self, email: str) -> sqlite3.Row | None:
with self._tx() as conn:
return conn.execute("SELECT id, email, pw_hash FROM users WHERE email = ?", (email,)).fetchone()
def set_password(self, user_id: int, pw_hash: str, expect_pw_hash: str | None = None) -> bool:
"""С expect_pw_hash меняет пароль, только если он всё ещё прежний (перехеширование не затирает сброс пароля)."""
with self._tx(immediate=True) as conn:
if expect_pw_hash is None:
cursor = conn.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (pw_hash, user_id))
else:
cursor = conn.execute("UPDATE users SET pw_hash = ? WHERE id = ? AND pw_hash = ?", (pw_hash, user_id, expect_pw_hash))
return cursor.rowcount > 0
def delete_user(self, user_id: int) -> None:
"""Удаляет пользователя вместе с сессиями, токенами и прогрессом (каскад внешних ключей)."""
with self._tx(immediate=True) as conn:
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
# --- регистрация ---
def save_registration(self, email: str, token_hash: str, expires_at: float, now: float) -> None:
with self._tx(immediate=True) as conn:
conn.execute(
"INSERT OR REPLACE INTO registrations (email, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)",
(email, token_hash, int(expires_at), int(now)),
)
def peek_registration(self, token_hash: str, now: float) -> str | None:
with self._tx() as conn:
row = conn.execute(
"SELECT email FROM registrations WHERE token_hash = ? AND expires_at > ?", (token_hash, int(now))
).fetchone()
return row["email"] if row else None
def activate_registration(self, token_hash: str, pw_hash: str, now: float) -> bool:
"""Гасит ссылку и создаёт пользователя одной транзакцией. False: ссылка недействительна или адрес уже занят."""
with self._tx(immediate=True) as conn:
row = conn.execute(
"DELETE FROM registrations WHERE token_hash = ? AND expires_at > ? RETURNING email", (token_hash, int(now))
).fetchone()
if row is None:
return False
try:
conn.execute(
"INSERT INTO users (email, pw_hash, created_at) VALUES (?, ?, ?)", (row["email"], pw_hash, int(now))
)
except sqlite3.IntegrityError:
return False # неудавшийся INSERT не отменяет транзакцию: ссылка всё равно погашена
return True
# --- сброс пароля ---
def save_reset_token(self, user_id: int, token_hash: str, expires_at: float) -> None:
with self._tx(immediate=True) as conn:
conn.execute("DELETE FROM reset_tokens WHERE user_id = ?", (user_id,))
conn.execute(
"INSERT INTO reset_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
(token_hash, user_id, int(expires_at)),
)
def peek_reset_token(self, token_hash: str, now: float) -> sqlite3.Row | None:
with self._tx() as conn:
return conn.execute(
"SELECT u.id AS id, u.email AS email FROM reset_tokens t JOIN users u ON u.id = t.user_id "
"WHERE t.token_hash = ? AND t.expires_at > ?",
(token_hash, int(now)),
).fetchone()
def reset_password(self, token_hash: str, pw_hash: str, now: float) -> sqlite3.Row | None:
"""Гасит токен, меняет пароль и завершает все сессии одной транзакцией. None: токен недействителен."""
with self._tx(immediate=True) as conn:
row = conn.execute(
"DELETE FROM reset_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id", (token_hash, int(now))
).fetchone()
if row is None:
return None
conn.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (pw_hash, row["user_id"]))
conn.execute("DELETE FROM sessions WHERE user_id = ?", (row["user_id"],))
return conn.execute("SELECT id, email FROM users WHERE id = ?", (row["user_id"],)).fetchone()
# --- сессии ---
def create_session(self, user_id: int, token_hash: str, expires_at: float, now: float, keep: int = 10,
expect_pw_hash: str | None = None) -> bool:
"""expect_pw_hash защищает от гонки: если пароль сменили после проверки, сессия не создаётся."""
with self._tx(immediate=True) as conn:
if expect_pw_hash is not None:
current = conn.execute("SELECT pw_hash FROM users WHERE id = ?", (user_id,)).fetchone()
if current is None or current["pw_hash"] != expect_pw_hash:
return False
conn.execute(
"INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
(token_hash, user_id, int(expires_at), int(now)),
)
conn.execute(
"DELETE FROM sessions WHERE user_id = ? AND token_hash NOT IN "
"(SELECT token_hash FROM sessions WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?)",
(user_id, user_id, keep),
)
return True
def session_user(self, token_hash: str, now: float) -> sqlite3.Row | None:
with self._tx() as conn:
return conn.execute(
"SELECT u.id AS id, u.email AS email FROM sessions s JOIN users u ON u.id = s.user_id "
"WHERE s.token_hash = ? AND s.expires_at > ?",
(token_hash, int(now)),
).fetchone()
def delete_session(self, token_hash: str) -> None:
with self._tx(immediate=True) as conn:
conn.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
def delete_user_sessions(self, user_id: int) -> None:
with self._tx(immediate=True) as conn:
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
# --- состояние прогресса ---
def get_state(self, user_id: int) -> tuple[int, str] | None:
with self._tx() as conn:
row = conn.execute("SELECT version, data FROM user_state WHERE user_id = ?", (user_id,)).fetchone()
return (row["version"], row["data"]) if row else None
def put_state(self, user_id: int, expected_version: int, data: str, now: float) -> int | None:
"""Записывает состояние, если версия совпала (оптимистичная блокировка). None = конфликт."""
with self._tx(immediate=True) as conn:
row = conn.execute("SELECT version FROM user_state WHERE user_id = ?", (user_id,)).fetchone()
current = row["version"] if row else 0
if current != expected_version:
return None
new_version = current + 1
conn.execute(
"INSERT INTO user_state (user_id, data, version, updated_at) VALUES (?, ?, ?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, version = excluded.version, updated_at = excluded.updated_at",
(user_id, data, new_version, int(now)),
)
return new_version
# --- обслуживание ---
def purge_expired(self, now: float) -> None:
with self._tx(immediate=True) as conn:
for table in ("registrations", "reset_tokens", "sessions"):
conn.execute(f"DELETE FROM {table} WHERE expires_at <= ?", (int(now),)) # имена таблиц - константы выше
def count(self, table: str) -> int:
if table not in _COUNTABLE:
raise ValueError("неизвестная таблица")
with self._tx() as conn:
return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
View File
+810
View File
@@ -0,0 +1,810 @@
"""Интеграционные тесты API: реальный HTTP-сервер, SQLite во временной папке, почта в памяти."""
import http.client
import json
import os
import re
import sqlite3
import tempfile
import threading
import unittest
from unittest import mock
from splitapi import security
from splitapi.config import from_env
from splitapi.http_api import is_trusted_proxy_peer, make_server, normalize_ip
from splitapi.mailer import MailQueueFull, MemoryMailer
from splitapi.ratelimit import RateLimiter
from splitapi.service import Service
from splitapi.store import Store
PASSWORD = "correct horse battery"
OTHER_PASSWORD = "another staple pony"
_ORIGINAL_N = security.SCRYPT_N
WRONG = "definitely wrong pass"
def setUpModule():
security.SCRYPT_N = 2**10 # быстрые хеши в тестах; боевое значение проверено в test_security
def tearDownModule():
security.SCRYPT_N = _ORIGINAL_N
class Clock:
def __init__(self):
self.now = 1_800_000_000.0
def __call__(self):
return self.now
def advance(self, seconds):
self.now += seconds
class Resp:
def __init__(self, status, headers, body):
self.status, self.headers, self.text = status, headers, body
self.set_cookies = []
try:
self.json = json.loads(body) if body else None
except ValueError:
self.json = None
def header(self, name):
return self.headers.get(name)
class ApiTestCase(unittest.TestCase):
public_url = "http://127.0.0.1:8080"
env_extra: dict = {}
def make_mailer(self):
return MemoryMailer()
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.db_path = os.path.join(self.tmp.name, "api.sqlite3")
self.cfg = from_env({"PUBLIC_URL": self.public_url, "DB_PATH": self.db_path, "MAIL_BACKEND": "console", **self.env_extra})
self.clock = Clock()
self.mail = self.make_mailer()
self.service = Service(self.cfg, Store(self.db_path), self.mail, RateLimiter(clock=self.clock), clock=self.clock)
self.server = make_server(self.cfg, self.service, host="127.0.0.1", port=0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=lambda: self.server.serve_forever(poll_interval=0.01), daemon=True)
self.thread.start()
self.addCleanup(self._stop)
self.cookie = None
def _stop(self):
self.server.shutdown()
self.server.server_close()
# --- помощники ---
def call(self, method, path, body=None, headers=None, raw=None, csrf=True, cookie="auto"):
hdrs = {}
if method not in ("GET", "HEAD", "OPTIONS") and csrf:
hdrs.update({"Content-Type": "application/json", "X-Requested-With": "split-trainer", "Origin": self.cfg.origin})
if cookie == "auto":
cookie = self.cookie
if cookie:
hdrs["Cookie"] = f"{self.cfg.cookie_name}={cookie}"
hdrs.update(headers or {})
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
conn.request(method, path, body=payload, headers=hdrs)
r = conn.getresponse()
resp = Resp(r.status, {k: v for k, v in r.getheaders()}, r.read().decode("utf-8"))
resp.set_cookies = [v for k, v in r.getheaders() if k.lower() == "set-cookie"]
return resp
finally:
conn.close()
def register(self, email="user@example.com", **extra):
return self.call("POST", "/api/register", {"email": email, **extra})
def mails_to(self, email):
return [m for m in self.mail.sent if m.to == email]
def token_from(self, email, kind, index=-1):
mail = self.mails_to(email)[index]
match = re.search(rf"#{kind}=([A-Za-z0-9_-]+)", mail.body)
self.assertIsNotNone(match, f"в письме нет ссылки {kind}: {mail.body}")
return match.group(1)
def confirm(self, email="user@example.com", password=PASSWORD):
return self.call("POST", "/api/verify", {"token": self.token_from(email, "verify"), "password": password})
def login(self, email="user@example.com", password=PASSWORD, remember=True, headers=None):
r = self.call("POST", "/api/login", {"email": email, "password": password}, cookie=None, headers=headers)
if r.status == 200 and remember:
self.cookie = re.match(rf"{re.escape(self.cfg.cookie_name)}=([^;]+)", r.set_cookies[0]).group(1)
return r
def whoami(self, cookie="auto"):
r = self.call("GET", "/api/me", cookie=cookie)
self.assertEqual(r.status, 200) # гостю тоже 200: так в консоли браузера нет «ошибок» при каждом заходе
return r.json["email"]
def make_user(self, email="user@example.com", password=PASSWORD):
self.assertEqual(self.register(email).status, 200)
self.assertEqual(self.confirm(email, password).status, 200)
self.assertEqual(self.login(email, password).status, 200)
def fail_logins(self, count, email="user@example.com", headers=None):
for _ in range(count):
self.assertEqual(self.login(email, WRONG, remember=False, headers=headers).status, 401)
class BasicTests(ApiTestCase):
def test_health(self):
r = self.call("GET", "/api/health")
self.assertEqual((r.status, r.json), (200, {"ok": True}))
def test_unknown_path_and_wrong_method(self):
self.assertEqual(self.call("GET", "/api/nope").status, 404)
r = self.call("GET", "/api/login")
self.assertEqual(r.status, 405)
self.assertIn("POST", r.header("Allow"))
self.assertEqual(self.call("POST", "/api/health", {}).status, 405)
def test_head_and_options_are_not_supported(self):
self.assertEqual(self.call("HEAD", "/api/health").status, 405)
self.assertEqual(self.call("OPTIONS", "/api/login").status, 405)
def test_responses_are_not_cacheable_and_nosniff(self):
r = self.call("GET", "/api/health")
self.assertEqual(r.header("Cache-Control"), "no-store")
self.assertEqual(r.header("X-Content-Type-Options"), "nosniff")
self.assertTrue(r.header("Content-Type").startswith("application/json"))
class RegistrationTests(ApiTestCase):
def test_register_sends_confirmation_link_and_creates_no_session(self):
r = self.register()
self.assertEqual((r.status, r.json["ok"]), (200, True))
self.assertEqual(len(self.mails_to("user@example.com")), 1)
self.assertIn("http://127.0.0.1:8080/#verify=", self.mails_to("user@example.com")[0].body)
self.assertEqual(r.set_cookies, [])
def test_registration_never_sets_a_password(self):
"""Пароль выбирает тот, у кого есть доступ к ящику, поэтому подменить его чужой регистрацией нельзя."""
self.register(password="attackers-password-1")
self.assertEqual(self.login(password="attackers-password-1", remember=False).status, 401)
self.assertEqual(self.confirm(password=OTHER_PASSWORD).status, 200)
self.assertEqual(self.login(password="attackers-password-1", remember=False).status, 401)
self.assertEqual(self.login(password=OTHER_PASSWORD).status, 200)
def test_cannot_login_before_confirmation(self):
self.register()
r = self.login()
self.assertEqual((r.status, r.json["error"]), (401, "invalid_credentials"))
def test_confirmation_creates_account_with_chosen_password_without_logging_in(self):
self.register()
r = self.confirm()
self.assertEqual(r.status, 200)
self.assertEqual(r.set_cookies, [])
self.assertEqual(self.login().status, 200)
def test_confirmation_token_is_single_use(self):
self.register()
token = self.token_from("user@example.com", "verify")
self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 200)
r = self.call("POST", "/api/verify", {"token": token, "password": OTHER_PASSWORD})
self.assertEqual((r.status, r.json["error"]), (400, "invalid_token"))
self.assertEqual(self.login(password=OTHER_PASSWORD, remember=False).status, 401)
def test_confirmation_token_expires(self):
self.register()
token = self.token_from("user@example.com", "verify")
self.clock.advance(24 * 3600 + 1)
self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).json["error"], "invalid_token")
def test_garbage_tokens_are_rejected(self):
for token in ["", "short", None, 123, "x" * 500, "../../etc/passwd", {"a": 1}]:
with self.subTest(token=token):
self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 400)
def test_weak_password_at_confirmation_does_not_burn_the_link(self):
self.register()
token = self.token_from("user@example.com", "verify")
for bad, code in [("short", "password_too_short"), ("password", "password_weak"), ("x" * 200, "password_too_long"),
("user@example.com", "password_weak"), (None, "password_too_short")]:
with self.subTest(bad=bad):
self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": bad}).json["error"], code)
self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 200)
def test_lone_surrogate_password_is_a_clean_400_not_a_500(self):
self.register()
token = self.token_from("user@example.com", "verify")
r = self.call("POST", "/api/verify", raw=b'{"token": "%s", "password": "\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800"}' % token.encode())
self.assertEqual((r.status, r.json["error"]), (400, "password_invalid"))
self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 200)
def test_latest_registration_link_wins_and_older_dies(self):
self.register()
old = self.token_from("user@example.com", "verify")
self.clock.advance(1)
self.register()
self.assertEqual(self.call("POST", "/api/verify", {"token": old, "password": PASSWORD}).status, 400)
self.assertEqual(self.confirm().status, 200)
def test_registering_existing_email_is_indistinguishable_and_sends_notice_only(self):
fresh = self.register("fresh@example.com")
self.make_user("user@example.com")
count_before = len(self.mails_to("user@example.com"))
again = self.register("user@example.com")
self.assertEqual((again.status, again.json), (fresh.status, fresh.json))
notice = self.mails_to("user@example.com")[count_before:]
self.assertEqual(len(notice), 1)
self.assertNotIn("#verify=", notice[0].body)
self.assertEqual(self.login(remember=False).status, 200)
def test_account_taken_while_link_was_pending_cannot_be_overwritten(self):
self.register()
stale = self.token_from("user@example.com", "verify")
self.register()
self.assertEqual(self.confirm(password=PASSWORD).status, 200)
self.assertEqual(self.call("POST", "/api/verify", {"token": stale, "password": OTHER_PASSWORD}).status, 400)
self.assertEqual(self.login(password=PASSWORD, remember=False).status, 200)
def test_invalid_emails_are_rejected_without_sending_mail(self):
for bad in ["not-an-email", "a\n@example.com", "user@example\n.com", "a%b@example.com", ["a@example.com"], None, 5]:
with self.subTest(bad=bad):
self.assertEqual(self.register(bad).json["error"], "invalid_email")
self.clock.advance(1000) # не упираемся в лимит регистраций с одного IP
self.assertEqual(self.mail.sent, [])
def test_email_is_normalized(self):
self.register(" User@Example.COM ")
self.assertEqual(len(self.mails_to("user@example.com")), 1)
self.confirm("user@example.com")
self.assertEqual(self.login("USER@example.com").status, 200)
def test_registration_rate_limit_per_ip(self):
statuses = [self.register(f"u{i}@example.com").status for i in range(7)]
self.assertEqual(statuses[:5], [200] * 5)
self.assertEqual(statuses[5:], [429, 429])
self.assertIsNotNone(self.register("late@example.com").header("Retry-After"))
def test_per_email_limit_stays_silent(self):
for _ in range(3):
self.assertEqual(self.register("same@example.com").status, 200)
self.clock.advance(1000) # чтобы не упереться в лимит по IP
before = len(self.mails_to("same@example.com"))
r = self.register("same@example.com")
self.assertEqual((r.status, r.json["ok"]), (200, True))
self.assertEqual(len(self.mails_to("same@example.com")), before)
def test_expired_registrations_are_purged_periodically_not_on_every_request(self):
self.register("old@example.com")
self.clock.advance(24 * 3600 + 1)
self.register("new1@example.com") # первая чистка
self.assertEqual(Store(self.db_path).count("registrations"), 1)
self.clock.advance(1)
self.register("new2@example.com")
self.assertEqual(Store(self.db_path).count("registrations"), 2)
class RegistrationClosedTests(ApiTestCase):
env_extra = {"ALLOW_REGISTRATION": "false"}
def test_registration_can_be_disabled(self):
r = self.register()
self.assertEqual((r.status, r.json["error"]), (403, "registration_closed"))
self.assertEqual(self.mail.sent, [])
class MailQueueFullMailer:
def send(self, mail):
raise MailQueueFull
class MailOverloadTests(ApiTestCase):
def make_mailer(self):
return MailQueueFullMailer()
def test_overloaded_mail_queue_is_reported_instead_of_pretending_the_mail_was_sent(self):
r = self.register()
self.assertEqual((r.status, r.json["error"]), (503, "busy"))
self.assertIsNotNone(r.header("Retry-After"))
def test_forgot_does_not_reveal_which_addresses_exist_when_the_queue_is_full(self):
"""При переполненной очереди известный адрес не должен отвечать иначе, чем неизвестный."""
Store(self.db_path).create_user("known@example.com", security.hash_password(PASSWORD), self.clock())
known = self.call("POST", "/api/forgot", {"email": "known@example.com"})
unknown = self.call("POST", "/api/forgot", {"email": "ghost@example.com"})
self.assertEqual((known.status, known.json), (unknown.status, unknown.json))
self.assertEqual(known.status, 200)
def test_password_change_notice_failing_does_not_undo_the_reset(self):
Store(self.db_path).create_user("known@example.com", security.hash_password(PASSWORD), self.clock())
user = Store(self.db_path).user_by_email("known@example.com")
Store(self.db_path).save_reset_token(user["id"], security.hash_token("T" * 43), self.clock() + 3600)
r = self.call("POST", "/api/reset", {"token": "T" * 43, "password": OTHER_PASSWORD})
self.assertEqual(r.status, 200)
self.assertEqual(self.login("known@example.com", OTHER_PASSWORD, remember=False).status, 200)
class LoginTests(ApiTestCase):
def test_login_sets_hardened_cookie_and_me_works(self):
self.make_user()
r = self.call("POST", "/api/login", {"email": "user@example.com", "password": PASSWORD}, cookie=None)
cookie = r.set_cookies[0]
for flag in ("HttpOnly", "SameSite=Lax", "Path=/", "Max-Age=2592000"):
self.assertIn(flag, cookie)
self.assertNotIn("Secure", cookie) # PUBLIC_URL по http
self.assertEqual(self.whoami(), "user@example.com")
def test_wrong_password_and_unknown_user_look_identical(self):
self.make_user()
wrong = self.login(password=WRONG, remember=False)
unknown = self.login(email="ghost@example.com", remember=False)
self.assertEqual((wrong.status, wrong.json), (unknown.status, unknown.json))
self.assertEqual(wrong.status, 401)
def test_me_reports_guest_with_null_email(self):
self.assertIsNone(self.whoami(cookie=None))
self.assertIsNone(self.whoami(cookie="x" * 43))
def test_garbage_and_duplicate_cookies_mean_guest_not_an_error(self):
self.make_user()
for header in ["session=;;;====", "\x01\x02", "=", ";", f"{self.cfg.cookie_name}={self.cookie}; {self.cfg.cookie_name}=other"]:
with self.subTest(header=header):
r = self.call("GET", "/api/me", cookie=None, headers={"Cookie": header})
self.assertEqual((r.status, r.json["email"]), (200, None))
def test_logout_invalidates_session_and_clears_cookie(self):
self.make_user()
r = self.call("POST", "/api/logout", {})
self.assertEqual(r.status, 200)
self.assertIn("Max-Age=0", r.set_cookies[0])
self.assertIsNone(self.whoami())
def test_session_expires(self):
self.make_user()
self.clock.advance(30 * 24 * 3600 + 1)
self.assertIsNone(self.whoami())
def test_failed_logins_per_ip_are_limited_and_recover(self):
for i in range(20):
self.assertEqual(self.login(f"x{i}@example.com", WRONG, remember=False).status, 401)
r = self.login("y@example.com", WRONG, remember=False)
self.assertEqual(r.status, 429)
self.assertGreaterEqual(int(r.header("Retry-After")), 1)
self.clock.advance(601)
self.assertEqual(self.login("y@example.com", WRONG, remember=False).status, 401)
def test_successful_logins_do_not_use_up_the_ip_allowance(self):
"""Класс или семья за одним NAT входят много раз подряд: успехи не считаются."""
self.make_user()
for _ in range(30):
self.assertEqual(self.login(remember=False).status, 200)
def test_password_guessing_for_one_account_from_one_ip_is_blocked_even_with_the_right_password(self):
self.make_user()
self.fail_logins(5)
self.assertEqual(self.login(remember=False).status, 429)
def test_successful_logins_do_not_count_toward_the_account_lockout(self):
self.make_user()
self.fail_logins(4)
for _ in range(3):
self.assertEqual(self.login(remember=False).status, 200)
self.fail_logins(1)
self.assertEqual(self.login(remember=False).status, 429)
def test_lockout_expires(self):
self.make_user()
self.fail_logins(5)
self.clock.advance(601)
self.assertEqual(self.login(remember=False).status, 200)
def test_busy_hasher_gives_503_with_retry_after(self):
self.make_user()
with mock.patch.object(security, "_slots", threading.BoundedSemaphore(0)), mock.patch.object(security, "SLOT_WAIT", 0.05):
r = self.login(remember=False)
self.assertEqual((r.status, r.json["error"]), (503, "busy"))
self.assertIsNotNone(r.header("Retry-After"))
def test_lone_surrogate_password_at_login_is_a_clean_401(self):
self.make_user()
r = self.call("POST", "/api/login", raw=b'{"email": "user@example.com", "password": "\\ud800\\ud800\\ud800"}', cookie=None)
self.assertEqual((r.status, r.json["error"]), (401, "invalid_credentials"))
def test_rehash_never_overwrites_a_password_reset_that_happened_meanwhile(self):
"""Старый (скомпрометированный) пароль не должен вернуться из-за перехеширования, начавшегося до сброса."""
self.make_user()
store = Store(self.db_path)
uid = store.user_by_email("user@example.com")["id"]
real_hash = security.hash_password
def racing_hash(password, n=None):
store.set_password(uid, "HASH-AFTER-RESET") # владелец успел сменить пароль, пока считался новый хеш
return real_hash(password, n)
with mock.patch.object(security, "needs_rehash", return_value=True), mock.patch.object(security, "hash_password", racing_hash):
r = self.login(remember=False)
self.assertEqual((r.status, r.json["error"]), (401, "invalid_credentials"))
self.assertEqual(store.user_by_email("user@example.com")["pw_hash"], "HASH-AFTER-RESET")
self.assertIsNone(self.whoami(cookie=None))
def test_password_hash_is_upgraded_on_login(self):
self.make_user()
old_n = security.SCRYPT_N
security.SCRYPT_N = 2**11
try:
self.assertEqual(self.login(remember=False).status, 200)
stored = Store(self.db_path).user_by_email("user@example.com")["pw_hash"]
self.assertTrue(stored.startswith("scrypt$2048$"))
self.assertEqual(self.login(remember=False).status, 200) # новым хешем тоже входит
finally:
security.SCRYPT_N = old_n
class PasswordResetTests(ApiTestCase):
def test_forgot_for_unknown_email_is_indistinguishable_and_sends_nothing(self):
self.make_user()
known = self.call("POST", "/api/forgot", {"email": "user@example.com"})
unknown = self.call("POST", "/api/forgot", {"email": "ghost@example.com"})
self.assertEqual((known.status, known.json), (unknown.status, unknown.json))
self.assertEqual(self.mails_to("ghost@example.com"), [])
self.assertIn("#reset=", self.mails_to("user@example.com")[-1].body)
def test_forgot_rejects_malformed_email(self):
self.assertEqual(self.call("POST", "/api/forgot", {"email": "nope"}).json["error"], "invalid_email")
def test_full_reset_flow(self):
self.make_user()
old_cookie = self.cookie
self.call("POST", "/api/forgot", {"email": "user@example.com"})
token = self.token_from("user@example.com", "reset")
r = self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}, cookie=None)
self.assertEqual(r.status, 200)
self.assertEqual(self.login(password=PASSWORD, remember=False).status, 401)
self.assertEqual(self.login(password=OTHER_PASSWORD).status, 200)
self.assertIsNone(self.whoami(cookie=old_cookie)) # старые сессии отозваны
self.assertIn("пароль", self.mails_to("user@example.com")[-1].subject.lower())
def test_reset_token_is_single_use(self):
self.make_user()
self.call("POST", "/api/forgot", {"email": "user@example.com"})
token = self.token_from("user@example.com", "reset")
self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).status, 200)
r = self.call("POST", "/api/reset", {"token": token, "password": "third password!"})
self.assertEqual((r.status, r.json["error"]), (400, "invalid_token"))
def test_bad_new_password_does_not_burn_the_token(self):
self.make_user()
self.call("POST", "/api/forgot", {"email": "user@example.com"})
token = self.token_from("user@example.com", "reset")
self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": "123"}).json["error"], "password_too_short")
surrogate = self.call("POST", "/api/reset", raw=b'{"token": "%s", "password": "\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800"}' % token.encode())
self.assertEqual((surrogate.status, surrogate.json["error"]), (400, "password_invalid"))
self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).status, 200)
def test_reset_token_expires_after_one_hour(self):
self.make_user()
self.call("POST", "/api/forgot", {"email": "user@example.com"})
token = self.token_from("user@example.com", "reset")
self.clock.advance(3601)
self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).json["error"], "invalid_token")
def test_new_request_invalidates_previous_link(self):
self.make_user()
self.call("POST", "/api/forgot", {"email": "user@example.com"})
first = self.token_from("user@example.com", "reset")
self.clock.advance(5)
self.call("POST", "/api/forgot", {"email": "user@example.com"})
self.assertEqual(self.call("POST", "/api/reset", {"token": first, "password": OTHER_PASSWORD}).status, 400)
self.assertEqual(self.call("POST", "/api/reset", {"token": self.token_from("user@example.com", "reset"), "password": OTHER_PASSWORD}).status, 200)
def test_forgot_rate_limit_per_email_is_silent(self):
self.make_user()
for _ in range(5):
self.assertEqual(self.call("POST", "/api/forgot", {"email": "user@example.com"}).status, 200)
resets = [m for m in self.mails_to("user@example.com") if "#reset=" in m.body]
self.assertEqual(len(resets), 3)
def test_forgot_rate_limit_per_ip(self):
statuses = [self.call("POST", "/api/forgot", {"email": f"a{i}@example.com"}).status for i in range(7)]
self.assertEqual(statuses[-1], 429)
def test_reset_cannot_be_used_with_a_verify_token(self):
self.register()
token = self.token_from("user@example.com", "verify")
self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).status, 400)
def test_session_created_by_a_concurrent_login_does_not_survive_the_reset(self):
"""Вход, начавшийся со старым паролем, не должен пережить сброс пароля."""
self.make_user()
stale_hash = Store(self.db_path).user_by_email("user@example.com")["pw_hash"]
self.call("POST", "/api/forgot", {"email": "user@example.com"})
self.call("POST", "/api/reset", {"token": self.token_from("user@example.com", "reset"), "password": OTHER_PASSWORD})
user = Store(self.db_path).user_by_email("user@example.com")
self.assertFalse(Store(self.db_path).create_session(user["id"], "late-session-hash", 2**40, 1, expect_pw_hash=stale_hash))
class DeleteAccountTests(ApiTestCase):
def test_requires_login(self):
self.assertEqual(self.call("POST", "/api/delete-account", {"password": PASSWORD}, cookie=None).status, 401)
def test_wrong_password_keeps_the_account(self):
self.make_user()
r = self.call("POST", "/api/delete-account", {"password": WRONG})
self.assertEqual((r.status, r.json["error"]), (401, "wrong_password"))
self.assertEqual(self.login(remember=False).status, 200)
def test_deletes_account_progress_and_sessions(self):
self.make_user()
self.call("PUT", "/api/state", {"version": 0, "data": {"progress": {"ru-A1": {"best": 1, "acc": 1, "stars": 1, "tries": 1}}}})
r = self.call("POST", "/api/delete-account", {"password": PASSWORD})
self.assertEqual(r.status, 200)
self.assertIn("Max-Age=0", r.set_cookies[0])
self.assertIsNone(self.whoami())
self.assertEqual(self.login(remember=False).status, 401)
store = Store(self.db_path)
self.assertEqual((store.count("users"), store.count("user_state"), store.count("sessions")), (0, 0, 0))
def test_password_guessing_here_is_limited_too(self):
self.make_user()
for _ in range(5):
self.assertEqual(self.call("POST", "/api/delete-account", {"password": WRONG}).status, 401)
self.assertEqual(self.call("POST", "/api/delete-account", {"password": PASSWORD}).status, 429)
def test_email_can_register_again_after_deletion(self):
self.make_user()
self.call("POST", "/api/delete-account", {"password": PASSWORD})
self.cookie = None
self.make_user(password=OTHER_PASSWORD)
self.assertEqual(self.whoami(), "user@example.com")
class RequestHardeningTests(ApiTestCase):
def test_missing_csrf_header_is_rejected(self):
r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10},
headers={"Content-Type": "application/json", "Origin": self.cfg.origin}, csrf=False)
self.assertEqual((r.status, r.json["error"]), (403, "csrf"))
def test_foreign_origin_is_rejected_and_logged_with_expected_value(self):
with self.assertLogs("splitapi", level="WARNING") as logs:
r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers={"Origin": "https://evil.example"})
self.assertEqual((r.status, r.json["error"]), (403, "csrf"))
text = "\n".join(logs.output)
self.assertIn("https://evil.example", text)
self.assertIn(self.cfg.origin, text)
def test_origin_null_is_rejected(self):
r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers={"Origin": "null"})
self.assertEqual(r.status, 403)
def test_request_without_origin_but_with_custom_header_is_allowed(self):
hdrs = {"Content-Type": "application/json", "X-Requested-With": "split-trainer"}
r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers=hdrs, csrf=False)
self.assertEqual(r.status, 401)
def test_wrong_content_type_is_rejected(self):
r = self.call("POST", "/api/login", raw=b'{"email":"a@example.com"}', headers={"Content-Type": "text/plain"})
self.assertEqual(r.status, 415)
def test_invalid_json_and_non_object_bodies(self):
self.assertEqual(self.call("POST", "/api/login", raw=b"{not json").json["error"], "invalid_json")
self.assertEqual(self.call("POST", "/api/login", raw=b"[1,2,3]").json["error"], "invalid_json")
self.assertEqual(self.call("POST", "/api/login", raw=b"").status, 400)
self.assertEqual(self.call("POST", "/api/login", raw=b'{"a": NaN}').status, 400)
def test_oversized_body_is_rejected_before_reading(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
conn.putrequest("POST", "/api/login")
for k, v in {"Content-Type": "application/json", "X-Requested-With": "split-trainer", "Content-Length": "5000000"}.items():
conn.putheader(k, v)
conn.endheaders()
self.assertEqual(conn.getresponse().status, 413)
conn.close()
def test_errors_do_not_leak_internals(self):
r = self.call("POST", "/api/login", raw=b"\xff\xfe")
self.assertNotIn("Traceback", r.text)
self.assertLess(r.status, 500)
class StateTests(ApiTestCase):
GOOD = {"progress": {"ru-A1": {"best": 100, "acc": 95, "stars": 1, "tries": 1}}, "history": [], "keys": {"ru": {}, "en": {}}}
def test_state_requires_login(self):
self.assertEqual(self.call("GET", "/api/state", cookie=None).status, 401)
self.assertEqual(self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}, cookie=None).status, 401)
def test_roundtrip_and_versioning(self):
self.make_user()
first = self.call("GET", "/api/state")
self.assertEqual((first.json["version"], first.json["data"]["progress"]), (0, {}))
saved = self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD})
self.assertEqual((saved.status, saved.json["version"]), (200, 1))
got = self.call("GET", "/api/state").json
self.assertEqual((got["version"], got["data"]["progress"]["ru-A1"]["best"]), (1, 100))
def test_state_response_names_the_account_so_a_stale_tab_can_notice_a_switch(self):
self.make_user("a@example.com")
self.assertEqual(self.call("GET", "/api/state").json["email"], "a@example.com")
def test_saving_on_behalf_of_another_account_is_refused(self):
"""Вкладка, открытая под аккаунтом A, не должна записать данные в аккаунт B после смены входа в другой вкладке."""
self.make_user("b@example.com")
r = self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD, "email": "a@example.com"})
self.assertEqual((r.status, r.json["error"]), (409, "account_mismatch"))
self.assertEqual(self.call("GET", "/api/state").json["data"]["progress"], {})
ok = self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD, "email": "B@Example.com"})
self.assertEqual(ok.status, 200)
def test_saving_without_an_email_still_works_for_older_clients(self):
self.make_user()
self.assertEqual(self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}).status, 200)
def test_progress_can_be_cleared_by_saving_an_empty_state(self):
self.make_user()
self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD})
self.assertEqual(self.call("PUT", "/api/state", {"version": 1, "data": {}}).status, 200)
self.assertEqual(self.call("GET", "/api/state").json["data"]["progress"], {})
def test_stale_version_gets_conflict_with_current_state(self):
self.make_user()
self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD})
r = self.call("PUT", "/api/state", {"version": 0, "data": {"progress": {}, "history": [], "keys": {}}})
self.assertEqual((r.status, r.json["error"], r.json["version"]), (409, "version_conflict", 1))
self.assertIn("ru-A1", r.json["data"]["progress"])
def test_parallel_writes_with_the_same_version_let_exactly_one_through(self):
self.make_user()
statuses, gate = [], threading.Barrier(2)
def writer():
gate.wait()
statuses.append(self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}).status)
threads = [threading.Thread(target=writer) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(sorted(statuses), [200, 409])
def test_invalid_payloads(self):
self.make_user()
for body in [{"version": "x", "data": self.GOOD}, {"version": -1, "data": self.GOOD}, {"version": 0},
{"version": 0, "data": []}, {"version": 0, "data": {"progress": []}}]:
with self.subTest(body=body):
self.assertEqual(self.call("PUT", "/api/state", body).status, 400)
def test_lone_surrogate_key_does_not_crash_saving(self):
self.make_user()
raw = b'{"version": 0, "data": {"keys": {"ru": {"\\ud800": {"n": 1}}, "en": {}}}}'
self.assertEqual(self.call("PUT", "/api/state", raw=raw).status, 200)
def test_unknown_fields_are_not_stored(self):
self.make_user()
self.call("PUT", "/api/state", {"version": 0, "data": {**self.GOOD, "evil": "<script>alert(1)</script>"}})
self.assertNotIn("evil", self.call("GET", "/api/state").json["data"])
def test_state_is_isolated_between_users(self):
self.make_user("a@example.com")
self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD})
self.cookie = None
self.make_user("b@example.com", OTHER_PASSWORD)
self.assertEqual(self.call("GET", "/api/state").json["data"]["progress"], {})
class SecretsAtRestTests(ApiTestCase):
def test_database_never_contains_passwords_or_raw_tokens(self):
self.register()
verify_token = self.token_from("user@example.com", "verify")
self.confirm()
self.login()
self.call("POST", "/api/forgot", {"email": "user@example.com"})
reset_token = self.token_from("user@example.com", "reset")
conn = sqlite3.connect(self.db_path)
try:
dump = "\n".join(conn.iterdump())
finally:
conn.close()
for secret in (PASSWORD, verify_token, reset_token, self.cookie):
self.assertNotIn(secret, dump)
class HttpsCookieTests(ApiTestCase):
public_url = "https://trainer.example.com/app"
def test_cookie_is_secure_and_host_prefixed_when_site_is_https(self):
self.make_user()
r = self.call("POST", "/api/login", {"email": "user@example.com", "password": PASSWORD}, cookie=None)
cookie = r.set_cookies[0]
self.assertTrue(cookie.startswith("__Host-session="))
for flag in ("Secure", "HttpOnly", "SameSite=Lax", "Path=/"):
self.assertIn(flag, cookie)
self.assertNotIn("Domain", cookie)
def test_links_use_public_url_with_path_not_the_host_header(self):
self.register("user@example.com")
r = self.call("POST", "/api/forgot", {"email": "user@example.com"}, headers={"Host": "attacker.example"})
self.assertEqual(r.status, 200)
self.assertTrue(all("attacker.example" not in m.body for m in self.mail.sent))
self.assertIn("https://trainer.example.com/app/#verify=", self.mails_to("user@example.com")[0].body)
def test_origin_check_matches_normalized_public_url(self):
self.assertEqual(self.cfg.origin, "https://trainer.example.com")
r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers={"Origin": "https://trainer.example.com"})
self.assertEqual(r.status, 401)
class ClientIpTests(ApiTestCase):
env_extra = {"TRUST_PROXY": "1"}
def test_trusted_proxy_header_separates_clients(self):
for i in range(20):
self.login(f"x{i}@example.com", WRONG, remember=False, headers={"X-Real-IP": "203.0.113.5"})
blocked = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "203.0.113.5"})
other = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "203.0.113.6"})
self.assertEqual((blocked.status, other.status), (429, 401))
def test_attacker_cannot_lock_the_owner_out_from_another_address(self):
self.make_user()
self.fail_logins(5, headers={"X-Real-IP": "203.0.113.5"})
self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "203.0.113.5"}).status, 429)
self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "198.51.100.7"}).status, 200)
def test_distributed_guessing_hits_the_account_wide_ceiling(self):
self.make_user()
for n in range(20): # 20 адресов по 5 неудач = 100 неудач на один аккаунт за час
self.fail_logins(5, headers={"X-Real-IP": f"203.0.113.{n + 10}"})
self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "198.51.100.99"}).status, 429)
def test_the_account_wide_ceiling_leaves_room_for_the_owner_after_a_slow_attack(self):
"""Атака вполсилы (30 неудач за час с разных адресов) не должна запирать владельца."""
self.make_user()
for n in range(6):
self.fail_logins(5, headers={"X-Real-IP": f"203.0.113.{n + 10}"})
self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "198.51.100.99"}).status, 200)
def test_ipv6_clients_of_one_network_share_a_counter(self):
for i in range(20):
self.login(f"x{i}@example.com", WRONG, remember=False, headers={"X-Real-IP": f"2001:db8:1:2::{i + 1:x}"})
r = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "2001:db8:1:2:ffff:ffff:ffff:fff0"})
self.assertEqual(r.status, 429)
other = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "2001:db8:1:3::1"})
self.assertEqual(other.status, 401)
def test_garbage_proxy_header_falls_back_to_peer(self):
r = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "not-an-ip"})
self.assertEqual(r.status, 401)
class UntrustedProxyTests(ApiTestCase):
def test_header_is_ignored_when_proxy_is_not_trusted(self):
for i in range(20):
self.login(f"x{i}@example.com", WRONG, remember=False, headers={"X-Real-IP": f"203.0.113.{i + 10}"})
r = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "198.51.100.1"})
self.assertEqual(r.status, 429)
class IpHelpersTests(unittest.TestCase):
def test_only_private_and_loopback_peers_may_set_forwarded_headers(self):
for ok in ["127.0.0.1", "::1", "10.1.2.3", "172.17.0.1", "192.168.65.1", "fd00::1", "::ffff:10.0.0.5"]:
with self.subTest(ip=ok):
self.assertTrue(is_trusted_proxy_peer(ok))
for bad in ["8.8.8.8", "203.0.113.5", "2001:db8::1", "garbage", "", "::ffff:8.8.8.8"]:
with self.subTest(ip=bad):
self.assertFalse(is_trusted_proxy_peer(bad))
def test_ipv6_is_reduced_to_its_64_bit_network(self):
self.assertEqual(normalize_ip("2001:db8:1:2::1"), normalize_ip("2001:db8:1:2:ffff::9"))
self.assertNotEqual(normalize_ip("2001:db8:1:2::1"), normalize_ip("2001:db8:1:3::1"))
self.assertEqual(normalize_ip("203.0.113.5"), "203.0.113.5")
self.assertEqual(normalize_ip("::ffff:203.0.113.5"), "203.0.113.5")
self.assertIsNone(normalize_ip("nope"))
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""Тесты чтения настроек из переменных окружения."""
import os
import tempfile
import unittest
from splitapi.config import Config, ConfigError, from_env
BASE = {"PUBLIC_URL": "https://trainer.example.com"}
DEV_MAIL = {**BASE, "MAIL_BACKEND": "console"} # явное согласие на режим «письма в лог»
class ConfigTests(unittest.TestCase):
def test_minimal_local_config_has_safe_defaults(self):
cfg = from_env({"PUBLIC_URL": "http://127.0.0.1:8080"})
self.assertEqual(cfg.bind, "127.0.0.1")
self.assertEqual(cfg.port, 8000)
self.assertEqual(cfg.mail_backend, "console")
self.assertTrue(cfg.allow_registration)
self.assertFalse(cfg.trust_proxy)
def test_public_url_is_required_and_validated(self):
for env in [{}, {"PUBLIC_URL": ""}, {"PUBLIC_URL": "ftp://x.org"}, {"PUBLIC_URL": "not a url"},
{"PUBLIC_URL": "https://x.org/?q=1"}, {"PUBLIC_URL": "https://x.org/#frag"}, {"PUBLIC_URL": "https://"},
{"PUBLIC_URL": "https://user:pw@x.org"}]:
with self.subTest(env=env), self.assertRaises(ConfigError):
from_env({**env, "MAIL_BACKEND": "console"})
def test_derived_urls(self):
cfg = from_env({**DEV_MAIL, "PUBLIC_URL": "https://example.com/trainer"})
self.assertEqual(cfg.origin, "https://example.com")
self.assertEqual(cfg.base_url, "https://example.com/trainer/")
self.assertTrue(cfg.secure_cookie)
def test_http_url_gives_non_secure_cookie_and_keeps_port_in_origin(self):
cfg = from_env({"PUBLIC_URL": "http://127.0.0.1:8080/"})
self.assertEqual(cfg.origin, "http://127.0.0.1:8080")
self.assertEqual(cfg.base_url, "http://127.0.0.1:8080/")
self.assertFalse(cfg.secure_cookie)
def test_public_url_is_normalized_like_a_browser_origin(self):
cfg = from_env({**DEV_MAIL, "PUBLIC_URL": "https://Example.COM:443/app/"})
self.assertEqual((cfg.public_url, cfg.origin), ("https://example.com/app", "https://example.com"))
self.assertEqual(from_env({**DEV_MAIL, "PUBLIC_URL": "http://example.com:80"}).origin, "http://example.com")
self.assertEqual(from_env({**DEV_MAIL, "PUBLIC_URL": "https://example.com:8443"}).origin, "https://example.com:8443")
def test_international_domain_is_stored_as_punycode_like_browsers_send_it_in_origin(self):
cfg = from_env({**DEV_MAIL, "PUBLIC_URL": "https://тренер.рф/app"})
self.assertEqual(cfg.origin, "https://xn--e1aaxkdh.xn--p1ai")
self.assertEqual(cfg.public_url, "https://xn--e1aaxkdh.xn--p1ai/app")
def test_cookie_name_uses_host_prefix_only_over_https(self):
self.assertEqual(from_env(DEV_MAIL).cookie_name, "__Host-session")
self.assertEqual(from_env({"PUBLIC_URL": "http://127.0.0.1:8080"}).cookie_name, "session")
def test_smtp_host_switches_backend_to_smtp(self):
cfg = from_env({**BASE, "SMTP_HOST": "smtp.example.com", "SMTP_USER": "u", "SMTP_PASSWORD": "p"})
self.assertEqual(cfg.mail_backend, "smtp")
self.assertEqual((cfg.smtp_host, cfg.smtp_port, cfg.smtp_security), ("smtp.example.com", 587, "starttls"))
def test_console_mailer_is_not_the_silent_default_on_a_public_site(self):
with self.assertRaises(ConfigError) as ctx:
from_env(BASE)
self.assertIn("SMTP", str(ctx.exception))
for local in ("http://localhost:8080", "http://127.0.0.1", "http://[::1]:8080"):
with self.subTest(local=local):
self.assertEqual(from_env({"PUBLIC_URL": local}).mail_backend, "console")
def test_explicit_backend_wins_and_is_validated(self):
self.assertEqual(from_env({**BASE, "SMTP_HOST": "h", "MAIL_BACKEND": "console"}).mail_backend, "console")
with self.assertRaises(ConfigError):
from_env({**BASE, "MAIL_BACKEND": "carrier-pigeon"})
with self.assertRaises(ConfigError):
from_env({**BASE, "MAIL_BACKEND": "smtp"}) # без SMTP_HOST
def test_smtp_security_validated(self):
with self.assertRaises(ConfigError):
from_env({**BASE, "SMTP_HOST": "h", "SMTP_SECURITY": "magic"})
self.assertEqual(from_env({**BASE, "SMTP_HOST": "h", "SMTP_SECURITY": "ssl", "SMTP_PORT": "465"}).smtp_port, 465)
def test_password_from_file_for_docker_secrets(self):
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt") as f:
f.write("file-secret\n")
try:
cfg = from_env({**BASE, "SMTP_HOST": "h", "SMTP_PASSWORD_FILE": f.name})
self.assertEqual(cfg.smtp_password, "file-secret")
finally:
os.unlink(f.name)
def test_missing_password_file_is_an_error(self):
with self.assertRaises(ConfigError):
from_env({**BASE, "SMTP_HOST": "h", "SMTP_PASSWORD_FILE": "/nonexistent/secret"})
def test_booleans_and_numbers(self):
cfg = from_env({**DEV_MAIL, "ALLOW_REGISTRATION": "false", "TRUST_PROXY": "1", "PORT": "9000", "SESSION_DAYS": "7"})
self.assertFalse(cfg.allow_registration)
self.assertTrue(cfg.trust_proxy)
self.assertEqual((cfg.port, cfg.session_days), (9000, 7))
def test_invalid_values_rejected(self):
for env in [{"PORT": "0"}, {"PORT": "99999"}, {"PORT": "abc"}, {"SESSION_DAYS": "0"}, {"ALLOW_REGISTRATION": "maybe"}]:
with self.subTest(env=env), self.assertRaises(ConfigError):
from_env({**DEV_MAIL, **env})
def test_empty_values_fall_back_to_defaults(self):
"""docker compose передаёт незаданные переменные пустыми строками."""
cfg = from_env({**DEV_MAIL, "PORT": "", "SESSION_DAYS": "", "SMTP_PORT": "", "SMTP_SECURITY": "", "MAIL_FROM": ""})
self.assertEqual((cfg.port, cfg.session_days, cfg.smtp_port, cfg.smtp_security), (8000, 30, 587, "starttls"))
def test_default_sender_uses_site_host(self):
self.assertIn("noreply@trainer.example.com", from_env(DEV_MAIL).mail_from)
def test_config_is_immutable(self):
cfg = from_env(DEV_MAIL)
with self.assertRaises(Exception):
cfg.port = 1 # type: ignore[misc]
self.assertIsInstance(cfg, Config)
if __name__ == "__main__":
unittest.main()
+206
View File
@@ -0,0 +1,206 @@
"""Тесты отправки писем и шаблонов."""
import dataclasses
import logging
import threading
import unittest
from unittest import mock
from splitapi import mailer
from splitapi.config import from_env
CFG = from_env({"PUBLIC_URL": "https://example.com/trainer", "SMTP_HOST": "smtp.example.com", "SMTP_USER": "user",
"SMTP_PASSWORD": "pw", "MAIL_FROM": "Тренер <noreply@example.com>"})
class TemplateTests(unittest.TestCase):
def test_verify_mail_has_fragment_link_not_query(self):
mail = mailer.verify_mail(CFG, "u@example.com", "TOKEN123")
self.assertEqual(mail.to, "u@example.com")
self.assertIn("https://example.com/trainer/#verify=TOKEN123", mail.body)
self.assertNotIn("?verify", mail.body)
def test_reset_mail_has_fragment_link(self):
mail = mailer.reset_mail(CFG, "u@example.com", "TOK")
self.assertIn("https://example.com/trainer/#reset=TOK", mail.body)
self.assertIn("1 час", mail.body)
def test_validity_period_comes_from_the_config_not_from_a_constant(self):
longer = dataclasses.replace(CFG, reset_ttl=2 * 3600, verify_ttl=3 * 24 * 3600)
shorter = dataclasses.replace(CFG, reset_ttl=30 * 60)
self.assertIn("2 часа", mailer.reset_mail(longer, "u@example.com", "T").body)
self.assertIn("3 дня", mailer.verify_mail(longer, "u@example.com", "T").body)
self.assertIn("30 минут", mailer.reset_mail(shorter, "u@example.com", "T").body)
def test_human_duration_uses_correct_russian_plurals(self):
cases = {3600: "1 час", 7200: "2 часа", 5 * 3600: "5 часов", 21 * 3600: "21 час", 24 * 3600: "24 часа",
86400 * 3: "3 дня", 86400 * 7: "7 дней", 60: "1 минута", 120: "2 минуты", 15 * 60: "15 минут"}
for seconds, expected in cases.items():
with self.subTest(seconds=seconds):
self.assertEqual(mailer.human_duration(seconds), expected)
def test_notice_mails_have_no_tokens(self):
for mail in (mailer.already_registered_mail(CFG, "u@example.com"), mailer.password_changed_mail(CFG, "u@example.com")):
self.assertNotIn("#verify=", mail.body)
self.assertNotIn("#reset=", mail.body)
def test_messages_are_russian_and_have_subject(self):
for mail in (mailer.verify_mail(CFG, "u@example.com", "T"), mailer.reset_mail(CFG, "u@example.com", "T")):
self.assertTrue(mail.subject)
self.assertRegex(mail.body, r"[А-Яа-я]")
class BuildMessageTests(unittest.TestCase):
def test_headers_and_utf8_body(self):
msg = mailer.build_message(mailer.Mail("u@example.com", "Тема письма", "Текст на русском"), CFG.mail_from)
self.assertEqual(msg["To"], "u@example.com")
self.assertIn("noreply@example.com", msg["From"])
self.assertEqual(msg.get_content().strip(), "Текст на русском")
def test_header_injection_is_impossible(self):
for bad_to, bad_subject in [("a@example.com\r\nBcc: evil@x.org", "ok"), ("a@example.com", "x\r\nBcc: evil@x.org")]:
with self.subTest(to=bad_to, subject=bad_subject), self.assertRaises(ValueError):
mailer.build_message(mailer.Mail(bad_to, bad_subject, "body"), CFG.mail_from)
class MemoryMailerTests(unittest.TestCase):
def test_collects_messages(self):
m = mailer.MemoryMailer()
m.send(mailer.Mail("a@example.com", "s", "b"))
self.assertEqual([x.to for x in m.sent], ["a@example.com"])
class ConsoleMailerTests(unittest.TestCase):
def test_prints_message_to_log(self):
with self.assertLogs("splitapi.mail", level=logging.WARNING) as logs:
mailer.ConsoleMailer().send(mailer.Mail("a@example.com", "Тема", "Ссылка: https://x/#verify=ABC"))
self.assertIn("#verify=ABC", "\n".join(logs.output))
class SmtpMailerTests(unittest.TestCase):
def _send(self, security: str, port: int):
cfg = from_env({"PUBLIC_URL": "https://example.com", "SMTP_HOST": "smtp.example.com", "SMTP_PORT": str(port),
"SMTP_SECURITY": security, "SMTP_USER": "user", "SMTP_PASSWORD": "pw"})
target = "smtplib.SMTP_SSL" if security == "ssl" else "smtplib.SMTP"
with mock.patch(target) as smtp_cls:
server = smtp_cls.return_value.__enter__.return_value
mailer.SmtpMailer(cfg).send(mailer.Mail("u@example.com", "Тема", "Тело"))
return smtp_cls, server
def test_starttls_logs_in_and_sends(self):
smtp_cls, server = self._send("starttls", 587)
self.assertEqual(smtp_cls.call_args.args[:2], ("smtp.example.com", 587))
server.starttls.assert_called_once()
server.login.assert_called_once_with("user", "pw")
server.send_message.assert_called_once()
def test_ssl_does_not_call_starttls(self):
smtp_cls, server = self._send("ssl", 465)
server.starttls.assert_not_called()
server.login.assert_called_once()
server.send_message.assert_called_once()
def test_none_security_without_credentials_skips_login(self):
cfg = from_env({"PUBLIC_URL": "https://example.com", "SMTP_HOST": "localhost", "SMTP_SECURITY": "none", "SMTP_PORT": "25"})
with mock.patch("smtplib.SMTP") as smtp_cls:
server = smtp_cls.return_value.__enter__.return_value
mailer.SmtpMailer(cfg).send(mailer.Mail("u@example.com", "s", "b"))
server.starttls.assert_not_called()
server.login.assert_not_called()
server.send_message.assert_called_once()
def test_starttls_uses_verified_tls_context(self):
_, server = self._send("starttls", 587)
context = server.starttls.call_args.kwargs.get("context")
self.assertIsNotNone(context)
self.assertEqual(context.verify_mode.name, "CERT_REQUIRED")
class AsyncMailerTests(unittest.TestCase):
def test_delivers_in_background(self):
inner = mailer.MemoryMailer()
am = mailer.AsyncMailer(inner)
try:
am.send(mailer.Mail("a@example.com", "s", "b"))
am.flush(timeout=5)
self.assertEqual(len(inner.sent), 1)
finally:
am.close()
def test_failure_is_logged_and_worker_survives(self):
class Flaky:
def __init__(self):
self.calls = 0
self.done = threading.Event()
def send(self, mail):
self.calls += 1
if self.calls == 1:
raise OSError("smtp down")
self.done.set()
inner = Flaky()
am = mailer.AsyncMailer(inner)
try:
with self.assertLogs("splitapi.mail", level=logging.ERROR) as logs:
am.send(mailer.Mail("a@example.com", "s", "b"))
am.flush(timeout=5)
self.assertIn("OSError", "\n".join(logs.output))
am.send(mailer.Mail("b@example.com", "s", "b"))
self.assertTrue(inner.done.wait(5))
finally:
am.close()
def test_error_text_with_an_address_is_not_written_to_the_log(self):
class Refusing:
def send(self, mail):
raise OSError("recipient refused: private.person@secret-domain.example")
am = mailer.AsyncMailer(Refusing())
try:
with self.assertLogs("splitapi.mail", level=logging.ERROR) as logs:
am.send(mailer.Mail("a@example.com", "s", "b"))
am.flush(timeout=5)
text = "\n".join(logs.output)
self.assertNotIn("private.person", text)
self.assertNotIn("secret-domain", text)
finally:
am.close()
def test_full_queue_raises_instead_of_silently_dropping(self):
gate = threading.Event()
delivered = []
class Slow:
def send(self, mail):
gate.wait(5)
delivered.append(mail)
am = mailer.AsyncMailer(Slow(), maxsize=2)
rejected = 0
try:
with self.assertLogs("splitapi.mail", level=logging.ERROR):
for _ in range(10):
try:
am.send(mailer.Mail("a@example.com", "s", "b"))
except mailer.MailQueueFull:
rejected += 1
self.assertGreaterEqual(rejected, 7) # из 10 принято не больше 3: одно в работе, два в очереди
finally:
gate.set()
am.flush(timeout=5)
am.close()
self.assertEqual(len(delivered) + rejected, 10) # каждое письмо либо доставлено, либо явно отклонено
class MakeMailerTests(unittest.TestCase):
def test_selects_backend(self):
self.assertIsInstance(mailer.make_mailer(from_env({"PUBLIC_URL": "https://x.org", "MAIL_BACKEND": "console"})), mailer.ConsoleMailer)
m = mailer.make_mailer(CFG)
try:
self.assertIsInstance(m, mailer.AsyncMailer)
finally:
m.close()
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -0,0 +1,115 @@
"""Тесты точки входа: коды возврата при неверных настройках и healthcheck контейнера."""
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
from splitapi.config import from_env
from splitapi.http_api import make_server
from splitapi.mailer import MemoryMailer
from splitapi.ratelimit import RateLimiter
from splitapi.service import Service
from splitapi.store import Store
SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def run_module(env_overrides, *args):
env = {k: v for k, v in os.environ.items() if not k.startswith(("PUBLIC_URL", "SMTP_", "MAIL_", "PORT", "DB_PATH", "TRUST_PROXY"))}
env.update(env_overrides)
return subprocess.run([sys.executable, "-m", "splitapi", *args], cwd=SERVER_DIR, env=env, capture_output=True, text=True, timeout=60)
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class StartupTests(unittest.TestCase):
def test_missing_public_url_exits_with_code_2_and_explains(self):
result = run_module({})
self.assertEqual(result.returncode, 2)
self.assertIn("PUBLIC_URL", result.stderr)
def test_public_site_without_smtp_refuses_to_start(self):
result = run_module({"PUBLIC_URL": "https://trainer.example.com"})
self.assertEqual(result.returncode, 2)
self.assertIn("SMTP", result.stderr)
def test_explicit_console_mode_passes_configuration_checks_and_serves(self):
with tempfile.TemporaryDirectory() as tmp:
port = free_port()
env = {**{k: v for k, v in os.environ.items()}, "PUBLIC_URL": "https://trainer.example.com", "MAIL_BACKEND": "console",
"DB_PATH": os.path.join(tmp, "m.sqlite3"), "PORT": str(port)}
proc = subprocess.Popen([sys.executable, "-m", "splitapi"], cwd=SERVER_DIR, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
import time
deadline, ok = time.monotonic() + 20, False
while time.monotonic() < deadline and not ok:
ok = run_module({"PORT": str(port)}, "--healthcheck").returncode == 0
time.sleep(0.2)
self.assertTrue(ok, "процесс не начал отвечать на healthcheck")
finally:
proc.terminate()
try:
_, err = proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
_, err = proc.communicate()
self.assertIn("SMTP не настроен", err) # предупреждение о режиме разработки видно в логе
class SmtpWarningTests(unittest.TestCase):
def _start_and_collect_log(self, extra_env):
with tempfile.TemporaryDirectory() as tmp:
port = free_port()
env = {**os.environ, "PUBLIC_URL": "https://trainer.example.com", "DB_PATH": os.path.join(tmp, "w.sqlite3"), "PORT": str(port), **extra_env}
proc = subprocess.Popen([sys.executable, "-m", "splitapi"], cwd=SERVER_DIR, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
import time
deadline, ok = time.monotonic() + 20, False
while time.monotonic() < deadline and not ok:
ok = run_module({"PORT": str(port)}, "--healthcheck").returncode == 0
time.sleep(0.2)
self.assertTrue(ok)
finally:
proc.terminate()
try:
_, err = proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
_, err = proc.communicate()
return err
def test_unencrypted_smtp_to_a_remote_host_is_called_out(self):
err = self._start_and_collect_log({"SMTP_HOST": "mail.example.com", "SMTP_SECURITY": "none"})
self.assertIn("без шифрования", err)
def test_encrypted_smtp_gives_no_such_warning(self):
err = self._start_and_collect_log({"SMTP_HOST": "mail.example.com", "SMTP_SECURITY": "starttls"})
self.assertNotIn("без шифрования", err)
class HealthcheckTests(unittest.TestCase):
def test_fails_when_nothing_is_listening(self):
self.assertEqual(run_module({"PORT": str(free_port())}, "--healthcheck").returncode, 1)
def test_succeeds_against_a_running_server(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = from_env({"PUBLIC_URL": "http://127.0.0.1:8080", "DB_PATH": os.path.join(tmp, "h.sqlite3")})
server = make_server(cfg, Service(cfg, Store(cfg.db_path), MemoryMailer(), RateLimiter()), host="127.0.0.1", port=0)
thread = threading.Thread(target=lambda: server.serve_forever(poll_interval=0.01), daemon=True)
thread.start()
try:
self.assertEqual(run_module({"PORT": str(server.server_address[1])}, "--healthcheck").returncode, 0)
finally:
server.shutdown()
server.server_close()
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
"""Тесты ограничителя частоты запросов."""
import unittest
from splitapi.ratelimit import RateLimiter
class FakeClock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
class RateLimiterTests(unittest.TestCase):
def setUp(self):
self.clock = FakeClock()
self.limiter = RateLimiter(clock=self.clock)
def test_allows_up_to_limit_then_blocks(self):
for _ in range(3):
self.assertTrue(self.limiter.check("k", 3, 60)[0])
allowed, retry = self.limiter.check("k", 3, 60)
self.assertFalse(allowed)
self.assertGreaterEqual(retry, 1)
def test_window_slides(self):
for _ in range(3):
self.limiter.check("k", 3, 60)
self.clock.t += 61
self.assertTrue(self.limiter.check("k", 3, 60)[0])
def test_retry_after_matches_oldest_hit(self):
self.limiter.check("k", 2, 60)
self.clock.t += 20
self.limiter.check("k", 2, 60)
allowed, retry = self.limiter.check("k", 2, 60)
self.assertFalse(allowed)
self.assertEqual(retry, 40)
def test_keys_are_independent(self):
self.assertTrue(self.limiter.check("a", 1, 60)[0])
self.assertFalse(self.limiter.check("a", 1, 60)[0])
self.assertTrue(self.limiter.check("b", 1, 60)[0])
def test_blocked_attempts_do_not_extend_the_block(self):
self.limiter.check("k", 1, 60)
for _ in range(50):
self.limiter.check("k", 1, 60)
self.clock.t += 61
self.assertTrue(self.limiter.check("k", 1, 60)[0])
def test_is_blocked_does_not_count_attempts(self):
for _ in range(10):
self.assertFalse(self.limiter.is_blocked("k", 2, 60)[0])
def test_recorded_failures_lead_to_block_and_expire(self):
self.limiter.record("k", 60)
self.limiter.record("k", 60)
blocked, retry = self.limiter.is_blocked("k", 2, 60)
self.assertTrue(blocked)
self.assertGreaterEqual(retry, 1)
self.clock.t += 61
self.assertFalse(self.limiter.is_blocked("k", 2, 60)[0])
def test_forgive_gives_back_the_latest_attempt(self):
self.limiter.check("k", 2, 60)
self.limiter.check("k", 2, 60)
self.assertFalse(self.limiter.check("k", 2, 60)[0])
self.limiter.forgive("k")
self.assertTrue(self.limiter.check("k", 2, 60)[0])
def test_forgive_unknown_key_is_harmless(self):
self.limiter.forgive("never-seen")
self.assertTrue(self.limiter.check("never-seen", 1, 60)[0])
def test_successful_attempts_can_be_forgiven_indefinitely(self):
for _ in range(50):
self.assertTrue(self.limiter.check("ok", 3, 60)[0])
self.limiter.forgive("ok")
def test_expired_entries_are_dropped_before_live_ones(self):
limiter = RateLimiter(clock=self.clock, max_keys=3)
limiter.check("old1", 1, 60)
limiter.check("old2", 1, 60)
self.clock.t += 100
limiter.check("new1", 1, 60)
limiter.check("new2", 1, 60) # тут превышение: просроченные уходят первыми
self.assertEqual(limiter.size(), 2)
self.assertFalse(limiter.check("new1", 1, 60)[0]) # живой счётчик не потерян
def test_concurrent_checks_never_exceed_the_limit(self):
import threading
allowed = []
gate = threading.Barrier(16)
def worker():
gate.wait()
allowed.append(self.limiter.check("race", 5, 60)[0])
threads = [threading.Thread(target=worker) for _ in range(16)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(sum(allowed), 5)
def test_memory_is_bounded(self):
limiter = RateLimiter(clock=self.clock, max_keys=100)
for i in range(500):
limiter.check(f"k{i}", 5, 60)
self.clock.t += 0.01
self.assertLessEqual(limiter.size(), 100)
if __name__ == "__main__":
unittest.main()
+149
View File
@@ -0,0 +1,149 @@
"""Тесты примитивов безопасности: почта, пароли, токены."""
import base64
import threading
import unittest
from unittest import mock
from splitapi import security
class NormalizeEmailTests(unittest.TestCase):
def test_lowercases_and_strips(self):
self.assertEqual(security.normalize_email(" User@Example.COM "), "user@example.com")
def test_accepts_plus_and_dots(self):
self.assertEqual(security.normalize_email("a.b+tag@mail.example.org"), "a.b+tag@mail.example.org")
def test_idn_domain_is_converted_to_punycode(self):
self.assertEqual(security.normalize_email("ivan@почта.рф"), "ivan@xn--80a1acny.xn--p1ai")
def test_rejects_garbage(self):
for bad in ["", "plain", "a@b", "@example.com", "a@@example.com", "a b@example.com",
"a@exa mple.com", "a@example..com", "a@-example.com", "a@example.c", None, 42]:
with self.subTest(bad=bad):
self.assertIsNone(security.normalize_email(bad))
def test_rejects_newline_inside_address(self):
"""re.match с $ пропускал перевод строки перед концом строки: нужен fullmatch."""
for bad in ["a\n@example.com", "user@example\n.com", "a@example.com\n\n", "a@exam\nple.com"]:
with self.subTest(bad=bad):
result = security.normalize_email(bad)
self.assertTrue(result is None or "\n" not in result)
self.assertIsNone(security.normalize_email("a\n@example.com"))
self.assertIsNone(security.normalize_email("user@example\n.com"))
def test_rejects_characters_dangerous_for_legacy_mail_systems(self):
for bad in ["user%evil.com@example.com", "a|cmd@example.com", "a/b@example.com", "a!b@example.com", "a#b@example.com",
"a$b@example.com", "a&b@example.com", "a*b@example.com", "a=b@example.com", "a?b@example.com", "a`b@example.com"]:
with self.subTest(bad=bad):
self.assertIsNone(security.normalize_email(bad))
def test_keeps_apostrophe_in_real_names(self):
self.assertEqual(security.normalize_email("O'Brien@Example.com"), "o'brien@example.com")
def test_rejects_header_injection(self):
for bad in ["a@example.com\r\nBcc: evil@x.org", "a@example.com\nX: y", "a\x00@example.com"]:
with self.subTest(bad=bad):
self.assertIsNone(security.normalize_email(bad))
def test_rejects_too_long(self):
self.assertIsNone(security.normalize_email("a" * 65 + "@example.com"))
self.assertIsNone(security.normalize_email("a@" + "b" * 250 + ".com"))
class PasswordPolicyTests(unittest.TestCase):
def test_accepts_reasonable_password(self):
self.assertIsNone(security.validate_password("correct horse battery", "u@example.com"))
def test_too_short(self):
self.assertEqual(security.validate_password("short1", "u@example.com"), "password_too_short")
def test_too_long_is_rejected_to_bound_hashing_cost(self):
self.assertEqual(security.validate_password("x" * 129, "u@example.com"), "password_too_long")
def test_not_a_string(self):
self.assertEqual(security.validate_password(None, "u@example.com"), "password_too_short")
def test_equal_to_email_or_local_part(self):
self.assertEqual(security.validate_password("someone@example.com", "someone@example.com"), "password_weak")
self.assertEqual(security.validate_password("someoneelse1", "someoneelse1@example.com"), "password_weak")
def test_common_passwords_rejected(self):
for pw in ["password", "12345678", "qwertyuiop", "Password1"]:
with self.subTest(pw=pw):
self.assertEqual(security.validate_password(pw, "u@example.com"), "password_weak")
def test_unicode_password_length_counts_characters(self):
self.assertIsNone(security.validate_password("пароль-на-русском", "u@example.com"))
def test_lone_surrogates_are_rejected_instead_of_crashing_the_hasher(self):
"""json.loads пропускает "\\ud800": такая строка не кодируется в UTF-8 и раньше давала 500."""
for bad in ["\ud800" * 8, "valid-prefix-\udfff", "ok-password-" + "\udc00"]:
with self.subTest(bad=bad):
self.assertEqual(security.validate_password(bad, "u@example.com"), "password_invalid")
class PasswordHashTests(unittest.TestCase):
def test_roundtrip(self):
stored = security.hash_password("s3cret-password")
self.assertTrue(security.verify_password(stored, "s3cret-password"))
self.assertFalse(security.verify_password(stored, "S3cret-password"))
def test_hash_has_parameters_and_is_salted(self):
a, b = security.hash_password("same-password"), security.hash_password("same-password")
self.assertNotEqual(a, b)
self.assertTrue(a.startswith("scrypt$"))
self.assertNotIn("same-password", a)
def test_unicode_normalization_is_consistent(self):
stored = security.hash_password("éclair-1234") # e + combining acute
self.assertTrue(security.verify_password(stored, "éclair-1234")) # precomposed é
def test_malformed_stored_hash_never_verifies(self):
for bad in ["", "garbage", "scrypt$1$2", "scrypt$x$8$1$AAAA$BBBB", "md5$1$2$3$4$5"]:
with self.subTest(bad=bad):
self.assertFalse(security.verify_password(bad, "whatever-password"))
def test_needs_rehash_detects_weaker_parameters(self):
weak = security.hash_password("pw-for-rehash", n=2 ** 12)
self.assertTrue(security.needs_rehash(weak))
self.assertFalse(security.needs_rehash(security.hash_password("pw-for-rehash")))
def test_dummy_verify_takes_same_path_and_returns_false(self):
self.assertFalse(security.verify_dummy("any-password-here"))
def test_waiting_for_a_hash_slot_times_out_instead_of_hanging(self):
stored = "$".join(["scrypt", "1024", "8", "1", base64.b64encode(b"s" * 16).decode(), base64.b64encode(b"d" * 32).decode()])
with mock.patch.object(security, "_slots", threading.BoundedSemaphore(0)), mock.patch.object(security, "SLOT_WAIT", 0.05):
with self.assertRaises(security.HashBusy):
security.hash_password("some-password-1")
with self.assertRaises(security.HashBusy):
security.verify_password(stored, "some-password-1")
def test_hash_slot_is_released_after_use(self):
for _ in range(8): # больше, чем слотов: утечка слота зависла бы
security.hash_password("another-password", n=2**10)
class TokenTests(unittest.TestCase):
def test_tokens_are_random_and_long(self):
raw1, h1 = security.new_token()
raw2, h2 = security.new_token()
self.assertNotEqual(raw1, raw2)
self.assertGreaterEqual(len(raw1), 43)
self.assertNotEqual(h1, h2)
def test_hash_is_sha256_hex_and_deterministic(self):
raw, digest = security.new_token()
self.assertEqual(security.hash_token(raw), digest)
self.assertRegex(digest, r"^[0-9a-f]{64}$")
self.assertNotIn(raw, digest)
def test_hash_token_rejects_non_strings(self):
self.assertIsNone(security.hash_token(None))
self.assertIsNone(security.hash_token(123))
self.assertIsNone(security.hash_token("x" * 300))
if __name__ == "__main__":
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
"""Тесты проверки состояния прогресса, которое клиент сохраняет на сервере."""
import json
import unittest
from splitapi.statevalid import StateError, clean_state
GOOD = {
"progress": {"ru-A1": {"best": 120.5, "acc": 97.2, "stars": 2, "tries": 3}},
"history": [{"t": 1760000000000, "id": "ru-A1", "k": "lesson", "lang": "ru", "cpm": 120.5, "acc": 97.2, "err": 2, "len": 60, "sec": 30.1}],
"keys": {"ru": {"ф": {"n": 10, "err": 1, "ms": 2500, "ln": 9}}, "en": {}},
}
class CleanStateTests(unittest.TestCase):
def test_valid_state_roundtrips(self):
self.assertEqual(clean_state(GOOD), GOOD)
def test_empty_state_is_ok(self):
self.assertEqual(clean_state({}), {"progress": {}, "history": [], "keys": {"ru": {}, "en": {}}})
def test_unknown_top_level_fields_are_dropped(self):
out = clean_state({**GOOD, "settings": {"x": 1}, "evil": "<script>"})
self.assertNotIn("settings", out)
self.assertNotIn("evil", out)
def test_not_a_dict(self):
for bad in [None, [], "x", 5]:
with self.subTest(bad=bad), self.assertRaises(StateError):
clean_state(bad)
def test_wrong_section_types(self):
for bad in [{"progress": []}, {"history": {}}, {"keys": []}, {"keys": {"ru": []}}]:
with self.subTest(bad=bad), self.assertRaises(StateError):
clean_state(bad)
def test_numbers_are_coerced_and_clamped(self):
out = clean_state({"history": [{"t": 1, "cpm": "oops", "acc": 1e9, "err": -5, "len": 10, "sec": 1}]})
row = out["history"][0]
self.assertEqual(row["cpm"], 0)
self.assertEqual(row["acc"], 100)
self.assertEqual(row["err"], 0)
def test_strings_are_truncated_and_non_strings_dropped(self):
out = clean_state({"history": [{"t": 1, "id": "x" * 500, "lang": "<img onerror=1>", "k": 7}]})
row = out["history"][0]
self.assertLessEqual(len(row["id"]), 40)
self.assertEqual(row["lang"], "") # допустимы только ru и en
self.assertEqual(row["k"], "")
def test_history_is_capped(self):
rows = [{"t": i, "cpm": 1, "acc": 1, "err": 0, "len": 1, "sec": 1} for i in range(1000)]
out = clean_state({"history": rows})
self.assertEqual(len(out["history"]), 600)
self.assertEqual(out["history"][-1]["t"], 999) # хвост (самые свежие) сохраняется
def test_progress_keys_limited_and_stars_clamped(self):
prog = {f"id{i}": {"best": 1, "acc": 1, "stars": 99, "tries": 1} for i in range(500)}
out = clean_state({"progress": prog})
self.assertLessEqual(len(out["progress"]), 200)
self.assertTrue(all(v["stars"] <= 3 for v in out["progress"].values()))
def test_key_stats_limited(self):
keys = {"ru": {chr(0x400 + i): {"n": 1, "err": 0, "ms": 1, "ln": 1} for i in range(400)}, "en": {}}
out = clean_state({"keys": keys})
self.assertLessEqual(len(out["keys"]["ru"]), 200)
def test_lone_surrogate_key_is_dropped_so_serialization_cannot_fail(self):
out = clean_state({"keys": {"ru": {"\ud800": {"n": 1}, "ф": {"n": 1}}, "en": {}}})
self.assertEqual(list(out["keys"]["ru"]), ["ф"])
json.dumps(out, ensure_ascii=False).encode("utf-8") # не должно падать
def test_progress_is_truncated_in_sorted_order_like_the_client(self):
prog = {f"id{i:03d}": {"best": 1, "acc": 1, "stars": 1, "tries": 1} for i in reversed(range(300))}
out = clean_state({"progress": prog})
self.assertEqual(sorted(out["progress"])[0], "id000")
self.assertEqual(len(out["progress"]), 200)
self.assertEqual(sorted(out["progress"])[-1], "id199")
def test_multi_char_key_names_are_dropped(self):
out = clean_state({"keys": {"ru": {"ab": {"n": 1}, "ф": {"n": 1}}, "en": {}}})
self.assertEqual(list(out["keys"]["ru"]), ["ф"])
def test_output_is_json_serializable_and_bounded(self):
out = clean_state(GOOD)
self.assertLess(len(json.dumps(out)), 300_000)
def test_does_not_mutate_input(self):
original = json.loads(json.dumps(GOOD))
clean_state(GOOD)
self.assertEqual(GOOD, original)
if __name__ == "__main__":
unittest.main()
+262
View File
@@ -0,0 +1,262 @@
"""Тесты хранилища SQLite: атомарность одноразовых токенов, версии состояния, миграция, чистка просроченного."""
import os
import sqlite3
import tempfile
import threading
import unittest
from splitapi.store import Store
NOW = 1_000_000
class StoreTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "t.sqlite3")
self.store = Store(self.path)
self.addCleanup(self.tmp.cleanup)
def make_user(self, email="u@example.com", pw_hash="hash"):
uid = self.store.create_user(email, pw_hash, NOW)
self.assertIsNotNone(uid)
return uid
class UserTests(StoreTestCase):
def test_create_and_lookup(self):
uid = self.make_user()
user = self.store.user_by_email("u@example.com")
self.assertEqual((user["id"], user["pw_hash"]), (uid, "hash"))
self.assertIsNone(self.store.user_by_email("none@example.com"))
def test_duplicate_email_returns_none(self):
self.make_user()
self.assertIsNone(self.store.create_user("u@example.com", "other", NOW))
def test_set_password(self):
uid = self.make_user()
self.assertTrue(self.store.set_password(uid, "new"))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "new")
def test_set_password_can_be_conditional_on_the_previous_hash(self):
"""Перехеширование при входе не должно затирать пароль, который сменили, пока считался новый хеш."""
uid = self.make_user(pw_hash="old")
self.assertTrue(self.store.set_password(uid, "upgraded", expect_pw_hash="old"))
self.assertFalse(self.store.set_password(uid, "stale-upgrade", expect_pw_hash="old"))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "upgraded")
class RegistrationTests(StoreTestCase):
def test_registration_stores_no_password(self):
self.store.save_registration("u@example.com", "th1", NOW + 100, NOW)
conn = sqlite3.connect(self.path)
try:
columns = [row[1] for row in conn.execute("PRAGMA table_info(registrations)")]
finally:
conn.close()
self.assertNotIn("pw_hash", columns)
def test_peek_does_not_consume(self):
self.store.save_registration("u@example.com", "th1", NOW + 100, NOW)
self.assertEqual(self.store.peek_registration("th1", NOW), "u@example.com")
self.assertEqual(self.store.peek_registration("th1", NOW), "u@example.com")
self.assertIsNone(self.store.peek_registration("nope", NOW))
def test_expired_registration_is_invisible(self):
self.store.save_registration("u@example.com", "th1", NOW + 100, NOW)
self.assertIsNone(self.store.peek_registration("th1", NOW + 101))
self.assertFalse(self.store.activate_registration("th1", "pw", NOW + 101))
def test_activation_creates_user_once(self):
self.store.save_registration("u@example.com", "th1", NOW + 100, NOW)
self.assertTrue(self.store.activate_registration("th1", "pw-hash", NOW))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "pw-hash")
self.assertFalse(self.store.activate_registration("th1", "other", NOW))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "pw-hash")
def test_activation_fails_cleanly_if_the_address_was_taken(self):
self.store.save_registration("u@example.com", "th1", NOW + 100, NOW)
self.make_user("u@example.com", "existing")
self.assertFalse(self.store.activate_registration("th1", "intruder", NOW))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "existing")
self.assertIsNone(self.store.peek_registration("th1", NOW)) # ссылка погашена
def test_new_registration_replaces_previous_for_same_email(self):
self.store.save_registration("u@example.com", "th1", NOW + 100, NOW)
self.store.save_registration("u@example.com", "th2", NOW + 100, NOW)
self.assertIsNone(self.store.peek_registration("th1", NOW))
self.assertEqual(self.store.peek_registration("th2", NOW), "u@example.com")
def test_concurrent_activations_create_exactly_one_user(self):
self.store.save_registration("u@example.com", "th1", NOW + 100, NOW)
results, gate = [], threading.Barrier(8)
def worker(i):
gate.wait()
results.append(self.store.activate_registration("th1", f"hash{i}", NOW))
threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(results.count(True), 1)
self.assertEqual(self.store.count("users"), 1)
class ResetTokenTests(StoreTestCase):
def test_peek_does_not_consume(self):
uid = self.make_user()
self.store.save_reset_token(uid, "r1", NOW + 100)
self.assertEqual(self.store.peek_reset_token("r1", NOW)["email"], "u@example.com")
self.assertEqual(self.store.peek_reset_token("r1", NOW)["id"], uid)
self.assertIsNone(self.store.peek_reset_token("nope", NOW))
def test_reset_password_is_atomic_and_single_use(self):
uid = self.make_user()
self.store.create_session(uid, "s1", NOW + 1000, NOW)
self.store.save_reset_token(uid, "r1", NOW + 100)
result = self.store.reset_password("r1", "new-hash", NOW)
self.assertEqual((result["id"], result["email"]), (uid, "u@example.com"))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "new-hash")
self.assertIsNone(self.store.session_user("s1", NOW)) # сессии завершены в той же транзакции
self.assertIsNone(self.store.reset_password("r1", "again", NOW))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "new-hash")
def test_expired_token_changes_nothing(self):
uid = self.make_user()
self.store.create_session(uid, "s1", NOW + 1000, NOW)
self.store.save_reset_token(uid, "r1", NOW + 100)
self.assertIsNone(self.store.peek_reset_token("r1", NOW + 101))
self.assertIsNone(self.store.reset_password("r1", "new-hash", NOW + 101))
self.assertEqual(self.store.user_by_email("u@example.com")["pw_hash"], "hash")
self.assertIsNotNone(self.store.session_user("s1", NOW))
def test_new_token_invalidates_previous(self):
uid = self.make_user()
self.store.save_reset_token(uid, "r1", NOW + 100)
self.store.save_reset_token(uid, "r2", NOW + 100)
self.assertIsNone(self.store.reset_password("r1", "x", NOW))
self.assertIsNotNone(self.store.reset_password("r2", "x", NOW))
class SessionTests(StoreTestCase):
def test_session_lookup_and_expiry(self):
uid = self.make_user()
self.assertTrue(self.store.create_session(uid, "s1", NOW + 100, NOW))
self.assertEqual(self.store.session_user("s1", NOW)["email"], "u@example.com")
self.assertIsNone(self.store.session_user("s1", NOW + 101))
self.assertIsNone(self.store.session_user("nope", NOW))
def test_session_is_refused_if_password_changed_meanwhile(self):
"""Гонка вход/сброс: пароль сменили между проверкой старого пароля и созданием сессии."""
uid = self.make_user(pw_hash="old")
self.store.set_password(uid, "new")
self.assertFalse(self.store.create_session(uid, "s1", NOW + 100, NOW, expect_pw_hash="old"))
self.assertIsNone(self.store.session_user("s1", NOW))
self.assertTrue(self.store.create_session(uid, "s2", NOW + 100, NOW, expect_pw_hash="new"))
def test_delete_session_and_all_sessions(self):
uid = self.make_user()
for name in ("s1", "s2"):
self.store.create_session(uid, name, NOW + 100, NOW)
self.store.delete_session("s1")
self.assertIsNone(self.store.session_user("s1", NOW))
self.assertIsNotNone(self.store.session_user("s2", NOW))
self.store.delete_user_sessions(uid)
self.assertIsNone(self.store.session_user("s2", NOW))
def test_only_latest_sessions_are_kept(self):
uid = self.make_user()
for i in range(15):
self.store.create_session(uid, f"s{i}", NOW + 1000, NOW + i, keep=10)
alive = [i for i in range(15) if self.store.session_user(f"s{i}", NOW + 20)]
self.assertEqual(alive, list(range(5, 15)))
class StateTests(StoreTestCase):
def test_first_write_needs_version_zero(self):
uid = self.make_user()
self.assertIsNone(self.store.get_state(uid))
self.assertEqual(self.store.put_state(uid, 0, '{"a":1}', NOW), 1)
self.assertEqual(self.store.get_state(uid), (1, '{"a":1}'))
def test_stale_version_is_a_conflict(self):
uid = self.make_user()
self.store.put_state(uid, 0, "{}", NOW)
self.assertIsNone(self.store.put_state(uid, 0, '{"x":1}', NOW))
self.assertEqual(self.store.put_state(uid, 1, '{"x":1}', NOW), 2)
self.assertEqual(self.store.get_state(uid), (2, '{"x":1}'))
def test_concurrent_writes_with_one_version_let_exactly_one_through(self):
uid = self.make_user()
results, gate = [], threading.Barrier(8)
def worker(i):
gate.wait()
results.append(self.store.put_state(uid, 0, f'{{"w":{i}}}', NOW))
threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(len([r for r in results if r is not None]), 1)
def test_state_is_per_user(self):
a, b = self.make_user("a@example.com"), self.make_user("b@example.com")
self.store.put_state(a, 0, '{"who":"a"}', NOW)
self.assertIsNone(self.store.get_state(b))
def test_state_is_deleted_with_user(self):
uid = self.make_user()
self.store.put_state(uid, 0, "{}", NOW)
self.store.create_session(uid, "s1", NOW + 100, NOW)
self.store.delete_user(uid)
self.assertIsNone(self.store.get_state(uid))
self.assertIsNone(self.store.user_by_email("u@example.com"))
self.assertIsNone(self.store.session_user("s1", NOW))
class PurgeTests(StoreTestCase):
def test_purge_removes_only_expired(self):
uid = self.make_user()
self.store.save_registration("old@example.com", "reg-old", NOW - 1, NOW - 100)
self.store.save_registration("new@example.com", "reg-new", NOW + 100, NOW)
self.store.save_reset_token(uid, "rt-old", NOW - 1)
self.store.create_session(uid, "s-old", NOW - 1, NOW - 100)
self.store.create_session(uid, "s-new", NOW + 100, NOW)
self.store.purge_expired(NOW)
self.assertEqual(self.store.count("registrations"), 1)
self.assertEqual(self.store.count("reset_tokens"), 0)
self.assertEqual(self.store.count("sessions"), 1)
class SchemaTests(StoreTestCase):
def test_expiry_columns_are_indexed(self):
conn = sqlite3.connect(self.path)
try:
indexed = {row[0] for row in conn.execute("SELECT tbl_name FROM sqlite_master WHERE type='index' AND name LIKE '%expires%'")}
finally:
conn.close()
self.assertEqual(indexed, {"registrations", "reset_tokens", "sessions"})
def test_old_schema_with_password_column_is_migrated(self):
old = os.path.join(self.tmp.name, "old.sqlite3")
conn = sqlite3.connect(old)
conn.executescript(
"CREATE TABLE registrations (email TEXT PRIMARY KEY, pw_hash TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, "
"expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL);"
"INSERT INTO registrations VALUES ('x@example.com', 'h', 't', 99999999999, 1);"
)
conn.commit()
conn.close()
store = Store(old) # открытие мигрирует схему
store.save_registration("y@example.com", "th9", NOW + 100, NOW)
self.assertEqual(store.peek_registration("th9", NOW), "y@example.com")
self.assertIsNone(store.peek_registration("t", NOW)) # старые ожидающие регистрации с паролем отброшены
if __name__ == "__main__":
unittest.main()