Files
ForbiddenStarsApp/backend/tests/test_achievements.py
NotBigGhostandClaude Opus 5 1831a3e033 Ревью: закрыть дыры в собственном покрытии
Прогон /code-review по тестам показал, что проверка обхода каталога ачивок
ничего не проверяла: httpx нормализует «..» в URL до отправки, запрос уходил
на /api/admin/ и до обработчика не доходил — тест был бы зелёным и без
защиты. Теперь percent-кодированная форма плюс проверка конверта ошибки,
чтобы промах роутинга не выдавал себя за отказ.

Добавлено недостающее: передача владения группой (обратная сторона защиты
последнего владельца), сдвиг версии партии при загрузке вложения, совпадение
кэш-бастера аватара между профилем и лидербордом. Проверка выживания группы
после отказа в удалении теперь смотрит на саму группу и её партии, а не
только на код ответа.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
2026-09-09 18:32:47 +03:00

135 lines
5.6 KiB
Python

"""Ачивки: админ создаёт/правит/удаляет, грузит иконку; condition хранится как текст."""
from __future__ import annotations
import json
from pathlib import Path
from fastapi.testclient import TestClient
from tests.conftest import csrf_headers
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def _admin(client: TestClient, make_admin) -> None:
make_admin("admin", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
def _use_tmp_achievements(monkeypatch, tmp_path) -> Path:
from app.core.config import settings
monkeypatch.setattr(settings, "dev_achievements_dir", str(tmp_path))
return tmp_path
def test_create_list_and_files(client: TestClient, make_admin, monkeypatch, tmp_path):
root = _use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
r = client.post(
"/api/admin/achievements",
json={"name": "Великий полководец", "description": "10 побед подряд"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
ach = r.json()
slug = ach["slug"]
assert slug and ach["name"] == "Великий полководец"
# На диске создана папка с meta.json и condition.py (задел, не исполняется).
folder = root / slug
assert (folder / "meta.json").is_file()
assert (folder / "condition.py").is_file()
meta = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
assert meta["name"] == "Великий полководец"
# Виден в админском списке (редактор использует именно его).
assert any(a["slug"] == slug for a in client.get("/api/admin/achievements").json())
def test_duplicate_name_conflicts(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
body = {"name": "Берсерк"}
assert client.post("/api/admin/achievements", json=body, headers=csrf_headers(client)).status_code == 200
r = client.post("/api/admin/achievements", json=body, headers=csrf_headers(client))
assert r.status_code == 409, r.text
def test_icon_upload_and_serve(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
slug = client.post(
"/api/admin/achievements", json={"name": "Герой"}, headers=csrf_headers(client)
).json()["slug"]
# Не картинка → 422.
bad = client.put(
f"/api/admin/achievements/{slug}/icon",
files={"file": ("x.txt", b"nope", "text/plain")},
headers=csrf_headers(client),
)
assert bad.status_code == 422, bad.text
# Валидный PNG → icon_url, файл отдаётся.
ok = client.put(
f"/api/admin/achievements/{slug}/icon",
files={"file": ("h.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert ok.status_code == 200 and ok.json()["icon_url"], ok.text
g = client.get(f"/api/achievements/{slug}/icon")
assert g.status_code == 200 and g.content == PNG
def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
slug = client.post(
"/api/admin/achievements", json={"name": "Стратег"}, headers=csrf_headers(client)
).json()["slug"]
# Правка описания и текста условия (condition хранится как текст).
r = client.patch(
f"/api/admin/achievements/{slug}",
json={"description": "Победа по целям", "condition": "wins_by_objectives >= 5"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["description"] == "Победа по целям"
assert r.json()["has_condition"] is True
assert "wins_by_objectives" in r.json()["condition"]
# Удаление.
assert client.delete(
f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
).status_code == 200
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
def test_delete_rejects_traversal_slug(client: TestClient, make_admin, monkeypatch, tmp_path):
"""Slug из URL не должен уводить файловые операции за каталог ачивок.
Регрессия: `DELETE /api/admin/achievements/%2E%2E` снимал rmtree'ом родительскую
папку каталога (в проде это /data — БД, uploads и ачивки разом)."""
root = _use_tmp_achievements(monkeypatch, tmp_path / "achievements")
root.mkdir(parents=True, exist_ok=True)
sibling = tmp_path / "db.sqlite3"
sibling.write_bytes(b"data")
_admin(client, make_admin)
# Именно percent-кодированная форма: обычные точки httpx нормализует ещё до
# отправки, запрос уходит на /api/admin/ и до обработчика вовсе не доходит.
r = client.request(
"DELETE", "/api/admin/achievements/%2E%2E", headers=csrf_headers(client)
)
assert r.status_code == 404, r.text
assert r.json()["error"]["code"] == "NOT_FOUND" # ответ обработчика, а не промах роутинга
assert sibling.exists() and root.is_dir()