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.
95 lines
4.3 KiB
Python
95 lines
4.3 KiB
Python
"""Тесты проверки состояния прогресса, которое клиент сохраняет на сервере."""
|
|
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()
|