slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
"""Tests für die Highscore-API: Lesen, Einreichen, Validierung, Tokens, Rate-Limit."""
from __future__ import annotations
from collections.abc import Callable
import pytest
from flask import Flask
from flask.testing import FlaskClient
from app.models import get_db, insert_score
def _seed(app: Flask, entries: list[tuple[str, int]]) -> None:
with app.app_context():
db = get_db()
for name, score in entries:
insert_score(db, name, score, wave=1, duration_s=60)
# ------------------------------------------------------------------ GET /api/highscores
def test_highscores_empty(client: FlaskClient) -> None:
response = client.get("/api/highscores")
assert response.status_code == 200
assert response.get_json() == {"highscores": []}
assert response.headers["Cache-Control"] == "no-store"
def test_highscores_sorted_and_limited_to_top_10(app: Flask, client: FlaskClient) -> None:
_seed(app, [(f"Spieler {i}", i * 100) for i in range(15)])
scores = client.get("/api/highscores").get_json()["highscores"]
assert len(scores) == 10
assert [entry["score"] for entry in scores] == [1400 - i * 100 for i in range(10)]
assert [entry["rank"] for entry in scores] == list(range(1, 11))
assert set(scores[0]) == {"rank", "name", "score", "wave", "created_at"}
def test_highscore_ties_keep_older_entry_first(app: Flask, client: FlaskClient) -> None:
_seed(app, [("Erster", 500), ("Zweiter", 500)])
names = [entry["name"] for entry in client.get("/api/highscores").get_json()["highscores"]]
assert names == ["Erster", "Zweiter"]
# ----------------------------------------------------------------- POST /api/runs
def test_start_run_returns_token(client: FlaskClient) -> None:
response = client.post("/api/runs")
assert response.status_code == 201
token = response.get_json()["run_token"]
assert isinstance(token, str) and len(token) > 20
assert client.post("/api/runs").get_json()["run_token"] != token
# ----------------------------------------------------------- POST /api/highscores: Erfolg
def test_submit_valid_highscore(client: FlaskClient, valid_payload: Callable[..., dict]) -> None:
response = client.post("/api/highscores", json=valid_payload())
assert response.status_code == 201
body = response.get_json()
assert body["rank"] == 1
assert body["name"] == "Kadettin Ada"
assert body["highscores"][0]["score"] == 1234
listed = client.get("/api/highscores").get_json()["highscores"]
assert listed[0]["name"] == "Kadettin Ada"
assert listed[0]["wave"] == 3
def test_submit_returns_rank_below_existing(
app: Flask, client: FlaskClient, valid_payload: Callable[..., dict]
) -> None:
_seed(app, [("Profi", 5000), ("Mittel", 1500), ("Anfänger", 100)])
body = client.post("/api/highscores", json=valid_payload(score=1000)).get_json()
assert body["rank"] == 3
def test_submit_sanitizes_name(client: FlaskClient, valid_payload: Callable[..., dict]) -> None:
payload = valid_payload(name=" <b>Jürg</b>\u200b Größli \x00 ")
body = client.post("/api/highscores", json=payload).get_json()
assert body["name"] == "bJürgb Größli"
# ------------------------------------------------------ POST /api/highscores: Validierung
@pytest.mark.parametrize(
("overrides", "field"),
[
({"name": ""}, "name"),
({"name": " "}, "name"),
({"name": "<>{}"}, "name"),
({"name": "A" * 17}, "name"),
({"name": "A" * 500}, "name"),
({"name": 42}, "name"),
({"name": None}, "name"),
({"score": -1}, "score"),
({"score": "1000"}, "score"),
({"score": 12.5}, "score"),
({"score": True}, "score"),
({"score": None}, "score"),
({"score": 10**12}, "score"),
({"wave": 0}, "wave"),
({"wave": "3"}, "wave"),
({"wave": 100_000}, "wave"),
({"duration_s": -5}, "duration_s"),
({"duration_s": 10**9}, "duration_s"),
({"run_token": ""}, "run_token"),
({"run_token": 123}, "run_token"),
({"run_token": "x" * 2000}, "run_token"),
],
)
def test_submit_rejects_invalid_fields(
client: FlaskClient, valid_payload: Callable[..., dict], overrides: dict, field: str
) -> None:
response = client.post("/api/highscores", json=valid_payload(**overrides))
assert response.status_code == 400
body = response.get_json()
assert body["error"] == "validation_failed"
assert field in body["fields"]
def test_submit_reports_all_invalid_fields_at_once(client: FlaskClient) -> None:
response = client.post("/api/highscores", json={})
assert response.status_code == 400
assert set(response.get_json()["fields"]) == {
"name",
"score",
"wave",
"duration_s",
"run_token",
}
@pytest.mark.parametrize(
"kwargs",
[
{"data": "kein json", "content_type": "application/json"},
{"data": "name=Ada&score=5", "content_type": "application/x-www-form-urlencoded"},
{"json": ["liste", "statt", "objekt"]},
{"json": "nur ein string"},
],
)
def test_submit_rejects_non_object_bodies(client: FlaskClient, kwargs: dict) -> None:
response = client.post("/api/highscores", **kwargs)
assert response.status_code == 400
assert response.get_json()["error"] == "invalid_json"
def test_submit_rejects_oversized_body(client: FlaskClient) -> None:
response = client.post("/api/highscores", data="x" * 20_000, content_type="application/json")
assert response.status_code == 413
assert response.is_json
# ------------------------------------------------------ POST /api/highscores: Run-Token
def test_submit_rejects_forged_token(
client: FlaskClient, valid_payload: Callable[..., dict]
) -> None:
response = client.post("/api/highscores", json=valid_payload(run_token="gefälscht.token.xyz"))
assert response.status_code == 422
assert response.get_json()["error"] == "run_token_invalid"
def test_submit_rejects_token_signed_with_other_key(
client: FlaskClient, valid_payload: Callable[..., dict], db_path
) -> None:
from app import create_app
other = create_app(
{"TESTING": True, "SECRET_KEY": "anderer-key", "DATABASE_PATH": str(db_path)}
).test_client()
foreign_token = other.post("/api/runs").get_json()["run_token"]
response = client.post("/api/highscores", json=valid_payload(run_token=foreign_token))
assert response.get_json()["error"] == "run_token_invalid"
def test_submit_rejects_expired_token(
app: Flask,
client: FlaskClient,
issue_token: Callable[..., str],
valid_payload: Callable[..., dict],
) -> None:
token = issue_token(started_ago=app.config["RUN_TOKEN_MAX_AGE_SECONDS"] + 60)
response = client.post("/api/highscores", json=valid_payload(run_token=token))
assert response.status_code == 422
assert response.get_json()["error"] == "run_token_expired"
def test_submit_rejects_reused_token(
client: FlaskClient, issue_token: Callable[..., str], valid_payload: Callable[..., dict]
) -> None:
token = issue_token(started_ago=200)
assert client.post("/api/highscores", json=valid_payload(run_token=token)).status_code == 201
response = client.post("/api/highscores", json=valid_payload(run_token=token))
assert response.status_code == 409
assert response.get_json()["error"] == "run_token_used"
# -------------------------------------------------- POST /api/highscores: Plausibilität
def test_submit_rejects_duration_longer_than_run(
client: FlaskClient, issue_token: Callable[..., str], valid_payload: Callable[..., dict]
) -> None:
token = issue_token(started_ago=60)
response = client.post(
"/api/highscores", json=valid_payload(run_token=token, duration_s=600, score=10)
)
assert response.status_code == 422
assert response.get_json()["error"] == "implausible_score"
def test_submit_rejects_score_too_high_for_duration(
client: FlaskClient, valid_payload: Callable[..., dict]
) -> None:
response = client.post("/api/highscores", json=valid_payload(score=5_000_000, duration_s=60))
assert response.status_code == 422
assert response.get_json()["error"] == "implausible_score"
def test_submit_rejects_wave_unreachable_in_duration(
client: FlaskClient, valid_payload: Callable[..., dict]
) -> None:
response = client.post("/api/highscores", json=valid_payload(wave=80, duration_s=30, score=10))
assert response.status_code == 422
def test_implausible_submission_does_not_burn_token(
client: FlaskClient, issue_token: Callable[..., str], valid_payload: Callable[..., dict]
) -> None:
token = issue_token(started_ago=120)
bad = client.post("/api/highscores", json=valid_payload(run_token=token, score=9_999_999))
assert bad.status_code == 422
good = client.post("/api/highscores", json=valid_payload(run_token=token))
assert good.status_code == 201
# ------------------------------------------------------------------------ Rate-Limit
def test_rate_limit_blocks_after_max_attempts(
client: FlaskClient, valid_payload: Callable[..., dict]
) -> None:
for _ in range(5):
assert client.post("/api/highscores", json=valid_payload()).status_code == 201
response = client.post("/api/highscores", json=valid_payload())
assert response.status_code == 429
body = response.get_json()
assert body["error"] == "rate_limited"
retry_after = int(response.headers["Retry-After"])
assert 1 <= retry_after <= 600
assert body["retry_after"] == retry_after
def test_rate_limit_counts_invalid_requests_too(client: FlaskClient) -> None:
for _ in range(5):
assert client.post("/api/highscores", json={}).status_code == 400
assert client.post("/api/highscores", json={}).status_code == 429
def test_rate_limit_is_per_client(client: FlaskClient, valid_payload: Callable[..., dict]) -> None:
for _ in range(5):
client.post("/api/highscores", json={}, environ_base={"REMOTE_ADDR": "10.0.0.1"})
blocked = client.post("/api/highscores", json={}, environ_base={"REMOTE_ADDR": "10.0.0.1"})
assert blocked.status_code == 429
other = client.post(
"/api/highscores", json=valid_payload(), environ_base={"REMOTE_ADDR": "10.0.0.2"}
)
assert other.status_code == 201
def test_rate_limit_window_expires(
app: Flask, client: FlaskClient, monkeypatch: pytest.MonkeyPatch
) -> None:
import app.api as api_module
fake_now = [1_000_000.0]
monkeypatch.setattr(api_module, "_now", lambda: fake_now[0])
for _ in range(5):
client.post("/api/highscores", json={})
assert client.post("/api/highscores", json={}).status_code == 429
fake_now[0] += app.config["RATE_LIMIT_WINDOW_SECONDS"] + 1
assert client.post("/api/highscores", json={}).status_code == 400
def test_rate_limit_does_not_store_plain_ip(app: Flask, client: FlaskClient) -> None:
client.post("/api/highscores", json={}, environ_base={"REMOTE_ADDR": "203.0.113.7"})
with app.app_context():
hashes = [row[0] for row in get_db().execute("SELECT client_hash FROM submission_log")]
assert hashes
assert all("203.0.113.7" not in value for value in hashes)