Files
split-trainer/server/tests/test_config.py
T
darko fcafe1ae15 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.
2026-10-16 11:25:09 +03:00

121 lines
6.2 KiB
Python

"""Тесты чтения настроек из переменных окружения."""
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()