"""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=" Jürg\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)