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