diff --git a/backend/tests/test_achievements.py b/backend/tests/test_achievements.py index d940ded..a32dd23 100644 --- a/backend/tests/test_achievements.py +++ b/backend/tests/test_achievements.py @@ -111,3 +111,19 @@ def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path 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) + + r = client.delete("/api/admin/achievements/..", headers=csrf_headers(client)) + assert r.status_code == 404, r.text + assert sibling.exists() and root.is_dir() diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index 604e142..467aa0a 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -203,3 +203,49 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine detail = client.get(f"/api/admin/matches/{mid}").json() ap = next(p for p in detail["participants"] if p["user_id"] == me["id"]) assert ap["faction_name"] == "Орки WAAAGH" + + +def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine): + """Группу с партиями удалять нельзя — но ответ должен быть внятным 409. + + Регрессия: matches.group_id — ON DELETE RESTRICT, и голый session.delete ронял + IntegrityError наружу пятисоткой без конверта ошибки.""" + me = login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + p2 = add_group_member(engine, gid, "Игрок2") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + create_finished_match( + client, gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": p2, "faction_id": fids[1], "place": 2}, + ], + ) + + _admin_login(client, make_admin) + r = client.delete(f"/api/admin/groups/{gid}", headers=csrf_headers(client)) + assert r.status_code == 409, r.text + assert client.get("/api/admin/groups").status_code == 200 + + +def test_last_owner_cannot_demote_self(client: TestClient, engine): + """Единственный владелец не может разжаловать сам себя. + + Регрессия: change_role не проверял последнего владельца (в отличие от удаления), + и группа оставалась без владельца навсегда — назначить нового было некому.""" + me = login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + add_group_member(engine, gid, "Игрок2") + + r = client.patch( + f"/api/groups/{gid}/members/{me['id']}", + json={"role": "member"}, + headers=csrf_headers(client), + ) + assert r.status_code == 403, r.text + members = client.get(f"/api/groups/{gid}/members").json() + assert any(m["user_id"] == me["id"] and m["role"] == "owner" for m in members) diff --git a/backend/tests/test_concurrency.py b/backend/tests/test_concurrency.py index 516621f..fb7673d 100644 --- a/backend/tests/test_concurrency.py +++ b/backend/tests/test_concurrency.py @@ -75,3 +75,39 @@ def test_stale_finish_rejected(client: TestClient, engine): headers=csrf_headers(client), ) assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text + + +def test_participant_edit_moves_version(client: TestClient, engine): + """Правка одних участников тоже двигает версию партии. + + Регрессия: updated_at менялся только при UPDATE строки matches, поэтому после + правки участников версия оставалась прежней и вторая правка со старой версией + проходила вместо 409 — ровно то, от чего защищает блокировка.""" + me, p2, mid = _start(client, engine) + fin = finish_match( + client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}], + win_reason="objectives", + ) + assert fin.status_code == 200, fin.text + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + parts = {p["user_id"]: p["faction_id"] for p in client.get(f"/api/matches/{mid}").json()["participants"]} + swap = [ + {"user_id": me["id"], "faction_id": parts[me["id"]], "place": 2}, + {"user_id": p2, "faction_id": parts[p2], "place": 1}, + ] + r = client.patch( + f"/api/matches/{mid}", + json={"participants": swap, "expected_version": v1}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert client.get(f"/api/matches/{mid}").json()["version"] != v1 + + stale = client.patch( + f"/api/matches/{mid}", + json={"participants": swap, "expected_version": v1}, + headers=csrf_headers(client), + ) + assert stale.status_code == 409, stale.text + assert stale.json()["error"]["code"] == "STALE_WRITE"