299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""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)
|