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:
@@ -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()
|
||||
Reference in New Issue
Block a user