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

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

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

Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния.
Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
2026-10-16 11:25:09 +03:00

130 lines
6.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Тесты слияния и очистки прогресса (запуск: node --test tests/)
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { sanitize, merge, pick, emptyState } = require('../public/sync.js');
const row = (t, over = {}) => ({ t, id: 'ru-A1', k: 'lesson', lang: 'ru', cpm: 100, acc: 95, err: 1, len: 60, sec: 30, ...over });
const state = (over = {}) => ({ progress: {}, history: [], keys: { ru: {}, en: {} }, ...over });
test('emptyState has the expected shape', () => {
assert.deepEqual(emptyState(), { progress: {}, history: [], keys: { ru: {}, en: {} } });
});
test('sanitize: garbage input gives an empty state', () => {
for (const bad of [null, undefined, 5, 'x', [], true]) assert.deepEqual(sanitize(bad), emptyState());
});
test('sanitize: drops unknown fields and coerces numbers', () => {
const out = sanitize({
evil: '<script>', progress: { 'ru-A1': { best: '120', acc: 1e9, stars: 99, tries: -3, extra: 1 } },
history: [row(1, { cpm: 'oops', lang: '<img>', id: 'a b<c>' })], keys: { ru: { ab: { n: 1 }, 'ф': { n: 5, err: 1, ms: 10, ln: 2 } }, de: {} },
});
assert.equal('evil' in out, false);
assert.deepEqual(out.progress['ru-A1'], { best: 0, acc: 100, stars: 3, tries: 0 });
assert.equal(out.history[0].cpm, 0);
assert.equal(out.history[0].lang, '');
assert.equal(out.history[0].id, 'abc');
assert.deepEqual(Object.keys(out.keys.ru), ['ф']);
assert.equal('de' in out.keys, false);
});
test('sanitize: caps history to the latest 600 rows', () => {
const rows = Array.from({ length: 700 }, (_, i) => row(i + 1));
const out = sanitize({ history: rows });
assert.equal(out.history.length, 600);
assert.equal(out.history.at(-1).t, 700);
});
test('sanitize: the __proto__ key cannot replace the prototype of the progress map', () => {
const evil = JSON.parse('{"progress":{"__proto__":{"best":5,"acc":5,"stars":3,"tries":5},"ru-A1":{"best":1,"acc":1,"stars":1,"tries":1}}}');
const out = sanitize(evil);
assert.equal(Object.getPrototypeOf(out.progress), Object.prototype);
assert.equal(out.progress.best, undefined);
assert.deepEqual(Object.keys(out.progress), ['ru-A1']);
});
test('sanitize: progress and key tables are capped at 200 entries like on the server', () => {
const progress = {};
for (let i = 0; i < 300; i++) progress[`id${String(i).padStart(3, '0')}`] = { best: 1, acc: 1, stars: 1, tries: 1 };
const keys = { ru: {} };
for (let i = 0; i < 300; i++) keys.ru[String.fromCharCode(0x400 + i)] = { n: 1, err: 0, ms: 1, ln: 1 };
const out = sanitize({ progress, keys });
assert.equal(Object.keys(out.progress).length, 200);
assert.equal(Object.keys(out.progress)[0], 'id000'); // обрезка по отсортированному порядку, как на сервере
assert.equal(Object.keys(out.progress).at(-1), 'id199');
assert.equal(Object.keys(out.keys.ru).length, 200);
});
test('sanitize: lone surrogates are dropped (the server drops them too), whole emoji are kept', () => {
const out = sanitize({ keys: { ru: { '\ud800': { n: 1 }, '😀': { n: 2, err: 0, ms: 0, ln: 0 }, 'ф': { n: 3, err: 0, ms: 0, ln: 0 } }, en: {} } });
assert.deepEqual(Object.keys(out.keys.ru).sort(), ['ф', '😀'].sort());
});
test('merge: equal-sized key records are resolved the same way in either order', () => {
const a = state({ keys: { ru: { 'ф': { n: 5, err: 0, ms: 10, ln: 4 } }, en: {} } });
const b = state({ keys: { ru: { 'ф': { n: 5, err: 0, ms: 20, ln: 4 } }, en: {} } });
assert.deepEqual(merge(a, b), merge(b, a));
assert.equal(merge(a, b).keys.ru['ф'].ms, 20);
});
test('merge: progress takes the best value of each field', () => {
const a = state({ progress: { x: { best: 100, acc: 90, stars: 1, tries: 2 } } });
const b = state({ progress: { x: { best: 120, acc: 85, stars: 2, tries: 1 }, y: { best: 50, acc: 99, stars: 3, tries: 1 } } });
const m = merge(a, b);
assert.deepEqual(m.progress.x, { best: 120, acc: 90, stars: 2, tries: 2 });
assert.deepEqual(m.progress.y, { best: 50, acc: 99, stars: 3, tries: 1 });
});
test('merge: history is a deduplicated union ordered by time', () => {
const a = state({ history: [row(3), row(1)] });
const b = state({ history: [row(2), row(3)] });
assert.deepEqual(merge(a, b).history.map(r => r.t), [1, 2, 3]);
});
test('merge: history keeps at most 600 latest rows', () => {
const a = state({ history: Array.from({ length: 400 }, (_, i) => row(i + 1)) });
const b = state({ history: Array.from({ length: 400 }, (_, i) => row(i + 301)) });
const m = merge(a, b);
assert.equal(m.history.length, 600);
assert.equal(m.history.at(-1).t, 700);
});
test('merge: key stats keep the record with more samples', () => {
const a = state({ keys: { ru: { 'ф': { n: 10, err: 1, ms: 100, ln: 9 } }, en: {} } });
const b = state({ keys: { ru: { 'ф': { n: 50, err: 5, ms: 500, ln: 40 }, 'ы': { n: 3, err: 0, ms: 9, ln: 2 } }, en: {} } });
const m = merge(a, b);
assert.equal(m.keys.ru['ф'].n, 50);
assert.equal(m.keys.ru['ы'].n, 3);
});
test('merge is commutative and idempotent', () => {
const a = state({ progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(1), row(5)], keys: { ru: { 'а': { n: 4, err: 0, ms: 1, ln: 1 } }, en: {} } });
const b = state({ progress: { x: { best: 3, acc: 1, stars: 2, tries: 4 }, z: { best: 9, acc: 9, stars: 3, tries: 1 } }, history: [row(2), row(5)], keys: { ru: { 'а': { n: 6, err: 1, ms: 2, ln: 2 } }, en: { a: { n: 1, err: 0, ms: 1, ln: 1 } } } });
assert.deepEqual(merge(a, b), merge(b, a));
assert.deepEqual(merge(merge(a, b), b), merge(a, b));
assert.deepEqual(merge(merge(a, b), a), merge(a, b));
});
test('merge with an empty state returns the other state unchanged', () => {
const a = state({ progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(1)] });
const expected = { progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(1)], keys: { ru: {}, en: {} } };
assert.deepEqual(merge(a, emptyState()), expected);
assert.deepEqual(merge(emptyState(), a), expected);
assert.deepEqual(merge(emptyState(), emptyState()), emptyState());
});
test('merge does not mutate its inputs', () => {
const a = state({ progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(2), row(1)] });
const b = state({ history: [row(3)] });
const copyA = JSON.parse(JSON.stringify(a));
merge(a, b);
assert.deepEqual(a, copyA);
});
test('pick extracts only the synced parts of the app state', () => {
const S = { settings: { theme: 'dark' }, vial: { secret: 1 }, progress: { x: { best: 1, acc: 1, stars: 1, tries: 1 } }, history: [row(1)], keys: { ru: {}, en: {} } };
const p = pick(S);
assert.deepEqual(Object.keys(p).sort(), ['history', 'keys', 'progress']);
});