chore: восстановление репозитория из снапшота v0.3.1

Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sab.code.lab 2026-08-07 09:34:02 +02:00
commit 1c85091186
184 changed files with 33303 additions and 0 deletions

288
server/tests/smoke.sh Normal file
View file

@ -0,0 +1,288 @@
#!/bin/sh
# Smoke-тест серверного API этапов 8 и 8.1 (контракт: docs/INTERFACES.md).
# sh, без bashизмов. Поднимает php -S на server/ с временной БД в /tmp
# (через env GOLEARN_DB) и гонит curl-сценарий. Код выхода != 0 при любом
# фейле; php -S убивается, временные файлы удаляются.
#
# Этап 8.1: сервер поднимается с GOLEARN_TEST=1 — captcha.php отдаёт код
# в заголовке X-Captcha-Debug, а письма восстановления пароля пишутся
# в файл GOLEARN_TEST_MAIL вместо mail().
#
# Использование:
# sh server/tests/smoke.sh # PHP по умолчанию: /tmp/php-run
# PHP=/path/to/php sh server/tests/smoke.sh
# PORT=8100 sh server/tests/smoke.sh
set -u
PHP=${PHP:-/tmp/php-run}
PORT=${PORT:-8099}
BASE="http://127.0.0.1:$PORT"
SERVER_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
TMPD=$(mktemp -d /tmp/golearn-smoke.XXXXXX) || exit 1
DB="$TMPD/test.sqlite"
COOKIES="$TMPD/cookies.txt"
RESP="$TMPD/resp.txt"
SRVLOG="$TMPD/server.log"
BIG="$TMPD/big.json"
HEADERS="$TMPD/headers.txt"
CAPTCHA="$TMPD/captcha.svg"
MAIL="$TMPD/mail.log"
export GOLEARN_DB="$DB"
export GOLEARN_TEST=1
export GOLEARN_TEST_MAIL="$MAIL"
SRV_PID=""
FAIL=0
cleanup() {
if [ -n "$SRV_PID" ]; then
kill "$SRV_PID" 2>/dev/null
wait "$SRV_PID" 2>/dev/null
fi
rm -f "$DB" "$DB-wal" "$DB-shm" "$COOKIES" "$RESP" "$SRVLOG" "$BIG" \
"$HEADERS" "$CAPTCHA" "$MAIL"
rmdir "$TMPD" 2>/dev/null
}
trap cleanup EXIT
# req METHOD PATH DATA CLIENT
# DATA: строка JSON, @path — файл, '-' — без тела
# CLIENT: 'yes' — добавить X-GoLearn-Client: web, 'no' — без заголовка
# Печатает HTTP-код; тело ответа остаётся в $RESP.
req() {
_method=$1
_path=$2
_data=$3
_client=$4
set -- -s -o "$RESP" -w '%{http_code}' -X "$_method" \
-b "$COOKIES" -c "$COOKIES"
if [ "$_client" = "yes" ]; then
set -- "$@" -H 'X-GoLearn-Client: web'
fi
if [ "$_data" != "-" ]; then
set -- "$@" -H 'Content-Type: application/json' \
--data-binary "$_data"
fi
curl "$@" "$BASE$_path"
}
# get_captcha — GET /api/captcha.php с куки-джаром; заголовки ответа
# остаются в $HEADERS, SVG — в $CAPTCHA; печатает код из X-Captcha-Debug
# (тестовый режим GOLEARN_TEST=1).
get_captcha() {
curl -s -D "$HEADERS" -o "$CAPTCHA" \
-b "$COOKIES" -c "$COOKIES" "$BASE/api/captcha.php"
tr -d '\r' <"$HEADERS" \
| sed -n 's/^[Xx]-[Cc]aptcha-[Dd]ebug:[[:space:]]*//p' | head -n 1
}
check() {
# check ОПИСАНИЕ ОЖИДАЕМЫЙ_КОД ФАКТИЧЕСКИЙ_КОД
if [ "$2" = "$3" ]; then
echo "ok [$3] $1"
else
echo "FAIL [$3, ожидали $2] $1"
FAIL=1
fi
}
check_body() {
# check_body ОПИСАНИЕ ПАТТЕРН (фиксированная строка, grep -qF по $RESP)
if grep -qF "$2" "$RESP"; then
echo "ok [body] $1"
else
echo "FAIL [body] $1 — тело: $(cat "$RESP")"
FAIL=1
fi
}
check_hdr() {
# check_hdr ОПИСАНИЕ ПАТТЕРН (regexp, grep -qiE по $HEADERS)
if grep -qiE "$2" "$HEADERS"; then
echo "ok [hdr] $1"
else
echo "FAIL [hdr] $1 — заголовки: $(tr '\n' ' ' <"$HEADERS")"
FAIL=1
fi
}
# --- поднять сервер ---
"$PHP" -S 127.0.0.1:"$PORT" -t "$SERVER_DIR" >"$SRVLOG" 2>&1 &
SRV_PID=$!
_i=0
_ready=0
while [ "$_i" -lt 50 ]; do
if curl -s "$BASE/api/health.php" 2>/dev/null | grep -q '"status":"ok"'; then
_ready=1
break
fi
sleep 0.2 2>/dev/null || sleep 1
_i=$((_i + 1))
done
if [ "$_ready" != "1" ]; then
echo "FAIL: php -S не поднялся, лог:"
cat "$SRVLOG"
exit 1
fi
EMAIL="smoke@example.com"
PASS="password123"
NEWPASS="newpassword456"
# --- сценарий по контракту ---
_code=$(req GET /api/health.php - no)
check "GET health" 200 "$_code"
check_body "health status ok" '"status":"ok"'
# --- этап 8.1: капча ---
_code=$(curl -s -D "$HEADERS" -o "$CAPTCHA" -w '%{http_code}' \
-b "$COOKIES" -c "$COOKIES" "$BASE/api/captcha.php")
check "GET captcha" 200 "$_code"
check_hdr "captcha: Content-Type image/svg+xml" \
'^content-type:[[:space:]]*image/svg\+xml'
check_hdr "captcha: X-Captcha-Debug (GOLEARN_TEST=1)" '^x-captcha-debug:'
check_hdr "captcha: Set-Cookie сессии" '^set-cookie:'
if grep -qF '<svg' "$CAPTCHA"; then
echo "ok [body] captcha: тело — SVG"
else
echo "FAIL [body] captcha: тело не SVG — $(cat "$CAPTCHA")"
FAIL=1
fi
# register без капчи → 422 captcha (код в сессии сгорает при попытке)
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" yes)
check "register без капчи" 422 "$_code"
check_body "register без капчи: error=captcha" '"error":"captcha"'
# register с неверной капчей → 422 captcha
CAP=$(get_captcha)
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"00000\"}" yes)
check "register с неверной капчей" 422 "$_code"
check_body "register неверная капча: error=captcha" '"error":"captcha"'
# register с верной капчей (код из X-Captcha-Debug, общий куки-джар) → 201
CAP=$(get_captcha)
if [ -z "$CAP" ]; then
echo "FAIL [hdr] X-Captcha-Debug пуст — дальнейшая регистрация невозможна"
FAIL=1
fi
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"$CAP\"}" yes)
check "register с верной капчей" 201 "$_code"
# повторное использование той же капчи → 422 captcha (код одноразовый)
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"$CAP\"}" yes)
check "register с той же капчей повторно" 422 "$_code"
check_body "повтор капчи: error=captcha" '"error":"captcha"'
# повторный register того же email (со свежей капчей) → 409
CAP=$(get_captcha)
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"$CAP\"}" yes)
check "повторный register" 409 "$_code"
_code=$(req POST /api/register.php "{\"email\":\"other@example.com\",\"password\":\"$PASS\"}" no)
check "register без X-GoLearn-Client" 403 "$_code"
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"wrong-password\"}" yes)
check "login с неверным паролем" 401 "$_code"
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" yes)
check "login" 200 "$_code"
_code=$(req GET /api/me.php - no)
check "me" 200 "$_code"
check_body "me возвращает email" "\"email\":\"$EMAIL\""
_code=$(req PUT /api/progress.php '{"payload":{"lesson":3,"done":["a","b"]}}' yes)
check "PUT progress" 200 "$_code"
check_body "PUT progress отдаёт updatedAt" '"updatedAt":"'
_code=$(req GET /api/progress.php - no)
check "GET progress" 200 "$_code"
check_body "GET progress: payload совпадает" '"lesson":3'
check_body "GET progress: массив done совпадает" '"done":["a","b"]'
# тело > 256 КБ → 413
{ printf '{"payload":{"blob":"'; head -c 300000 /dev/zero | tr '\0' 'a'; printf '"}}'; } >"$BIG"
_code=$(req PUT /api/progress.php "@$BIG" yes)
check "PUT progress с телом >256 КБ" 413 "$_code"
# --- этап 8.1: восстановление пароля ---
_code=$(req POST /api/password-reset/request.php '{"email":"no-such-user@example.com"}' yes)
check "reset request: несуществующий email" 200 "$_code"
check_body "reset request несуществующий: ok" '"ok":true'
_code=$(req POST /api/password-reset/request.php "{\"email\":\"$EMAIL\"}" yes)
check "reset request: существующий email" 200 "$_code"
check_body "reset request существующий: ok" '"ok":true'
# письмо в тестовом режиме пишется в файл GOLEARN_TEST_MAIL
if [ -f "$MAIL" ] && grep -qF 'token=' "$MAIL"; then
echo "ok [mail] письмо содержит ссылку с token="
else
echo "FAIL [mail] письмо без token= — файл: $(cat "$MAIL" 2>/dev/null)"
FAIL=1
fi
TOKEN=$(tr -d '\r' <"$MAIL" 2>/dev/null \
| sed -n 's/.*token=\([0-9a-f]\{64\}\).*/\1/p' | tail -n 1)
if [ -n "$TOKEN" ]; then
echo "ok [mail] токен извлечён из письма"
else
echo "FAIL [mail] токен не извлекается из письма"
FAIL=1
fi
_code=$(req POST /api/password-reset/confirm.php '{"token":"0000000000000000000000000000000000000000000000000000000000000000","password":"whatever123"}' yes)
check "reset confirm: неверный токен" 400 "$_code"
check_body "reset confirm неверный: error=token_invalid" '"error":"token_invalid"'
_code=$(req POST /api/password-reset/confirm.php "{\"token\":\"$TOKEN\",\"password\":\"short\"}" yes)
check "reset confirm: пароль < 8" 422 "$_code"
_code=$(req POST /api/password-reset/confirm.php "{\"token\":\"$TOKEN\",\"password\":\"$NEWPASS\"}" yes)
check "reset confirm: верный токен" 200 "$_code"
check_body "reset confirm верный: ok" '"ok":true'
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" yes)
check "login со СТАРЫМ паролем после reset" 401 "$_code"
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"$NEWPASS\"}" yes)
check "login с НОВЫМ паролем после reset" 200 "$_code"
# rate limit reset request: 5 за 10 минут; уже сделано 2, 6-я → 429
_i=3
while [ "$_i" -le 5 ]; do
_code=$(req POST /api/password-reset/request.php "{\"email\":\"$EMAIL\"}" yes)
check "reset request, попытка $_i/5" 200 "$_code"
_i=$((_i + 1))
done
_code=$(req POST /api/password-reset/request.php "{\"email\":\"$EMAIL\"}" yes)
check "reset request: 6-я попытка за 10 минут" 429 "$_code"
check_body "reset request 429: error=rate_limited" '"error":"rate_limited"'
# rate limit login: 10 попыток за 10 минут, 11-я → 429
_i=1
while [ "$_i" -le 10 ]; do
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"bad-pass-$_i\"}" yes)
check "login неверный, попытка $_i/10" 401 "$_code"
_i=$((_i + 1))
done
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"bad-pass-11\"}" yes)
check "login: 11-я попытка за 10 минут" 429 "$_code"
check_body "429 error=rate_limited" '"error":"rate_limited"'
_code=$(req POST /api/logout.php - yes)
check "logout" 200 "$_code"
_code=$(req GET /api/me.php - no)
check "me после logout" 401 "$_code"
echo "---"
if [ "$FAIL" = "0" ]; then
echo "SMOKE OK"
else
echo "SMOKE FAILED"
fi
exit "$FAIL"