- services/scores-api: microservizio (solo stdlib) con SQLite WAL, idempotenza per submission_id, limiti di frequenza e validazione; immagine Docker non-root. - nginx: reverse proxy su /api/zombie-fps/ (stessa origine del gioco, niente CORS). - scripts/leaderboard.gd: client HTTP con retry idempotente; su desktop la classifica resta disattivata senza --api=, cosi' i test non sporcano la classifica pubblica. - scripts/player_profile.gd: nome, id anonimo e miglior punteggio in user:// e in window.localStorage; il nome scelto non viene piu' richiesto; riparazione dei profili con la stringa "null". - scripts/start_screen.gd: menu con nome, GIOCA e classifica; la partita parte solo dal pulsante (via la rete di sicurezza sull'input, Invio nel campo non avvia). - scripts/main.gd, player.gd: avvio esplicito, segnale died, nota dell'invio. - tools: test_scores_api.py (42), test_leaderboard (14), test_player_profile (21), test_start_screen (10); suite esistenti adeguate all'avvio manuale. - docs/HIGHSCORES.md + docs/WEB-DEPLOY.md aggiornati.
264 lines
14 KiB
Python
264 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Test dell'API della classifica: avvia il servizio su un database temporaneo
|
|
(nessun Docker richiesto, gira con python3) e verifica i casi limite.
|
|
|
|
python3 tools/test_scores_api.py
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APP = ROOT / "services/scores-api/app.py"
|
|
PASSED = []
|
|
|
|
|
|
def check(label: str, condition: bool, detail: str = "") -> None:
|
|
if condition:
|
|
PASSED.append(label)
|
|
print("PASS: %s" % label)
|
|
else:
|
|
print("FAIL: %s %s" % (label, detail))
|
|
raise SystemExit(1)
|
|
|
|
|
|
def free_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def call(url: str, method: str = "GET", payload=None, headers=None, raw=None, timeout=5.0):
|
|
data = raw if raw is not None else (json.dumps(payload).encode() if payload is not None else None)
|
|
request = urllib.request.Request(url, data=data, method=method)
|
|
for key, value in (headers or {}).items():
|
|
request.add_header(key, value)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
body = response.read().decode()
|
|
return response.status, (json.loads(body) if body else {})
|
|
except urllib.error.HTTPError as error:
|
|
body = error.read().decode()
|
|
try:
|
|
return error.code, json.loads(body) if body else {}
|
|
except json.JSONDecodeError:
|
|
return error.code, {"raw": body}
|
|
|
|
|
|
def start_service(db_path: Path, port: int, extra_env=None):
|
|
env = dict(os.environ, DATABASE_PATH=str(db_path), PORT=str(port), BIND="127.0.0.1")
|
|
env.update(extra_env or {})
|
|
process = subprocess.Popen([sys.executable, str(APP)], env=env,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
base = "http://127.0.0.1:%d" % port
|
|
for _ in range(100):
|
|
try:
|
|
if call(base + "/health", timeout=0.5)[0] == 200:
|
|
return process, base
|
|
except Exception:
|
|
time.sleep(0.1)
|
|
process.kill()
|
|
raise SystemExit("il servizio non si e' avviato:\n" + process.stdout.read())
|
|
|
|
|
|
def stop(process) -> None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
|
|
|
|
def submission(player: str, name: str, score: int, submission_id: str | None = None) -> dict:
|
|
return {"player_id": player, "name": name, "score": score,
|
|
"submission_id": submission_id or str(uuid.uuid4())}
|
|
|
|
|
|
def main() -> None:
|
|
workdir = Path(tempfile.mkdtemp(prefix="zombie-scores-"))
|
|
db = workdir / "scores.db"
|
|
port = free_port()
|
|
fast = {"POST_PER_HOUR": "1000", "POST_MIN_INTERVAL": "0.3", "GET_PER_MINUTE": "1000"}
|
|
process, base = start_service(db, port, fast)
|
|
json_header = {"Content-Type": "application/json"}
|
|
try:
|
|
# --- salute e classifica vuota ---
|
|
status, health = call(base + "/health")
|
|
check("health risponde 200 con nome e versione",
|
|
status == 200 and health["ok"] and health["service"] == "zombie-fps-scores" and health["rows"] == 0)
|
|
status, board = call(base + "/scores")
|
|
check("classifica vuota valida", status == 200 and board["top"] == [] and board["total"] == 0 and board["me"] is None)
|
|
|
|
# --- invio valido ---
|
|
alice, bob = "player-alice-0001", "player-bob-0002"
|
|
first_payload = submission(alice, "Alice", 1250)
|
|
status, first = call(base + "/scores", "POST", first_payload, json_header)
|
|
check("invio valido risponde 201 con id, posizione e totale",
|
|
status == 201 and first["ok"] and first["rank"] == 1 and first["total"] == 1
|
|
and len(first["id"]) == 36 and not first["duplicate"])
|
|
identifier = first["id"]
|
|
|
|
# --- idempotenza: il retry dello STESSO invio non duplica e non e' bloccato ---
|
|
# (caso reale: il client ritenta dopo un timeout, subito dopo il primo invio)
|
|
status, again = call(base + "/scores", "POST", first_payload, json_header)
|
|
check("retry con lo stesso submission_id e' idempotente",
|
|
status == 200 and again["duplicate"] and again["id"] == identifier and again["total"] == 1)
|
|
|
|
# --- un id diverso, stesso giocatore: seconda partita ---
|
|
time.sleep(0.35) # intervallo minimo fra invii dello stesso giocatore
|
|
status, second = call(base + "/scores", "POST", submission(alice, "Alice", 800), json_header)
|
|
check("seconda partita dello stesso giocatore viene salvata",
|
|
status == 201 and second["total"] == 2 and second["rank"] == 2)
|
|
|
|
# --- classifica: tutte le partite, ordinate ---
|
|
time.sleep(0.35)
|
|
call(base + "/scores", "POST", submission(bob, "Bob", 3000), json_header)
|
|
status, board = call(base + "/scores?limit=10&player=" + alice)
|
|
names = [row["name"] for row in board["top"]]
|
|
scores = [row["score"] for row in board["top"]]
|
|
ranks = [row["rank"] for row in board["top"]]
|
|
check("classifica mostra tutte le partite in ordine di punteggio",
|
|
status == 200 and names == ["Bob", "Alice", "Alice"] and scores == [3000, 1250, 800] and ranks == [1, 2, 3])
|
|
check("il riassunto personale riporta miglior punteggio, posizione e partite",
|
|
board["me"]["best"] == 1250 and board["me"]["rank"] == 2 and board["me"]["games"] == 2)
|
|
status, limited = call(base + "/scores?limit=2")
|
|
check("limit viene rispettato", len(limited["top"]) == 2 and limited["limit"] == 2)
|
|
status, huge = call(base + "/scores?limit=9999")
|
|
check("limit viene limitato al massimo", huge["limit"] == 50)
|
|
status, bad_limit = call(base + "/scores?limit=abc")
|
|
check("limit non numerico viene rifiutato", status == 400 and "limit" in bad_limit["error"])
|
|
|
|
# --- validazione ---
|
|
cases = [
|
|
("nome vuoto rifiutato", submission(alice, " ", 100), 400),
|
|
("nome troppo lungo rifiutato", submission(alice, "x" * 17, 100), 400),
|
|
("caratteri strani nel nome rifiutati", submission(alice, "Anna<script>", 100), 400),
|
|
("punteggio non intero rifiutato", submission(alice, "Alice", "1250"), 400),
|
|
("punteggio negativo rifiutato", submission(alice, "Alice", -50), 400),
|
|
("punteggio oltre il tetto rifiutato", submission(alice, "Alice", 200050), 400),
|
|
("punteggio non multiplo di 50 rifiutato", submission(alice, "Alice", 1249), 400),
|
|
("player_id troppo corto rifiutato", submission("abc", "Alice", 100), 400),
|
|
("submission_id non uuid rifiutato", submission(alice, "Alice", 100, "non-un-uuid"), 400),
|
|
("punteggio come booleano rifiutato", submission(alice, "Alice", True), 400),
|
|
]
|
|
for label, payload, expected in cases:
|
|
time.sleep(0.35)
|
|
status, body = call(base + "/scores", "POST", payload, json_header)
|
|
check(label, status == expected, "ottenuto %s %s" % (status, body))
|
|
|
|
# --- nomi accettati: accenti, cifre, punteggiatura ammessa ---
|
|
time.sleep(0.35)
|
|
status, accented = call(base + "/scores", "POST",
|
|
submission("player-accent-0003", "Niccolò_99", 450), json_header)
|
|
check("nome con accenti, cifre e underscore accettato", status == 201)
|
|
status, board = call(base + "/scores?limit=50&player=player-accent-0003")
|
|
names = [row["name"] for row in board["top"]]
|
|
check("il nome accentato e' conservato correttamente", "Niccolò_99" in names)
|
|
|
|
# --- iniezione SQL e JSON malformato ---
|
|
time.sleep(0.35)
|
|
status, injection = call(base + "/scores", "POST",
|
|
submission("player-inject-0004", "Robert'); DROP TABLE scores;--", 500), json_header)
|
|
check("tentativo di iniezione nel nome rifiutato dal controllo caratteri", status == 400)
|
|
status, health = call(base + "/health")
|
|
check("la tabella e' intatta dopo il tentativo", status == 200 and health["rows"] >= 3)
|
|
status, body = call(base + "/scores", "POST", None, json_header, raw=b"{rotto")
|
|
check("JSON malformato rifiutato con 400", status == 400 and "JSON" in body["error"])
|
|
status, body = call(base + "/scores", "POST", None, json_header, raw=b"{" + b"x" * 5000)
|
|
check("corpo oltre il limite risponde 413", status == 413)
|
|
status, body = call(base + "/scores", "POST", submission(alice, "Alice", 100), {"Content-Type": "text/plain"})
|
|
check("Content-Type sbagliato risponde 415", status == 415)
|
|
status, body = call(base + "/scores", "POST", None, json_header,
|
|
raw=json.dumps(["non", "un", "oggetto"]).encode())
|
|
check("corpo JSON che non e' un oggetto rifiutato", status == 400)
|
|
status, body = call(base + "/inesistente")
|
|
check("percorso sconosciuto risponde 404 JSON", status == 404 and "error" in body)
|
|
status, body = call(base + "/scores/extra", "POST", submission(alice, "Alice", 100), json_header)
|
|
check("POST su percorso sbagliato risponde 404", status == 404)
|
|
|
|
# --- limiti ant-abuso su un'istanza dedicata con soglie strette ---
|
|
limit_db = workdir / "limits.db"
|
|
limit_port = free_port()
|
|
strict = {"POST_PER_HOUR": "3", "POST_MIN_INTERVAL": "30", "GET_PER_MINUTE": "5"}
|
|
limit_process, limit_base = start_service(limit_db, limit_port, strict)
|
|
try:
|
|
for i in range(3):
|
|
status, body = call(limit_base + "/scores", "POST",
|
|
submission("player-limit-%04d" % i, "Limit", 100),
|
|
dict(json_header, **{"X-Real-IP": "203.0.113.10"}))
|
|
check("tre invii dallo stesso IP passano", status == 201)
|
|
status, body = call(limit_base + "/scores", "POST",
|
|
submission("player-limit-x", "Limit", 100),
|
|
dict(json_header, **{"X-Real-IP": "203.0.113.10"}))
|
|
check("il quarto invio dallo stesso IP e' bloccato con 429", status == 429)
|
|
status, body = call(limit_base + "/scores", "POST",
|
|
submission("player-limit-y", "Limit", 100),
|
|
dict(json_header, **{"X-Real-IP": "203.0.113.11"}))
|
|
check("un IP diverso non e' bloccato", status == 201)
|
|
immediate = submission("player-limit-z", "Limit", 100, str(uuid.uuid4()))
|
|
call(limit_base + "/scores", "POST", immediate, dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
|
status, body = call(limit_base + "/scores", "POST", immediate, dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
|
check("il retry identico passa anche con l'intervallo minimo attivo", status == 200 and body["duplicate"])
|
|
status, body = call(limit_base + "/scores", "POST",
|
|
submission("player-limit-z", "Limit", 150),
|
|
dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
|
check("un nuovo invio dello stesso giocatore entro l'intervallo minimo e' bloccato", status == 429)
|
|
# Tetto orario per IP, contato su un IP pulito: si invia finche' non blocca.
|
|
allowed = 0
|
|
for i in range(10):
|
|
status, _ = call(limit_base + "/scores", "POST",
|
|
submission("player-budget-%02d" % i, "Budget", 100),
|
|
dict(json_header, **{"X-Real-IP": "203.0.113.14"}))
|
|
if status == 201:
|
|
allowed += 1
|
|
else:
|
|
break
|
|
check("il tetto orario per IP consente esattamente 3 invii poi blocca",
|
|
allowed == 3 and status == 429, "consentiti %d, ultimo stato %s" % (allowed, status))
|
|
for i in range(5):
|
|
call(limit_base + "/scores?limit=1", headers={"X-Real-IP": "203.0.113.13"})
|
|
status, body = call(limit_base + "/scores?limit=1", headers={"X-Real-IP": "203.0.113.13"})
|
|
check("le letture eccessive dallo stesso IP sono bloccate con 429", status == 429)
|
|
finally:
|
|
stop(limit_process)
|
|
|
|
# --- determinismo dei campi restituiti ---
|
|
status, board = call(base + "/scores?limit=1")
|
|
first_row = board["top"][0]
|
|
check("ogni riga espone id univoco, player_id, nome, punteggio e data",
|
|
all(key in first_row for key in ("id", "player_id", "name", "score", "created_at"))
|
|
and first_row["score"] == 3000 and len(first_row["id"]) == 36)
|
|
status, board2 = call(base + "/scores?limit=50")
|
|
ids = [row["id"] for row in board2["top"]]
|
|
check("gli id delle righe sono tutti distinti", len(ids) == len(set(ids)))
|
|
|
|
# --- persistenza dopo riavvio del servizio ---
|
|
rows_before = call(base + "/health")[1]["rows"]
|
|
stop(process)
|
|
process, base = start_service(db, port, fast)
|
|
status, health = call(base + "/health")
|
|
check("i punteggi sopravvivono al riavvio del servizio",
|
|
status == 200 and health["rows"] == rows_before)
|
|
status, board = call(base + "/scores?limit=3")
|
|
check("la classifica e' ancora ordinata dopo il riavvio",
|
|
[row["score"] for row in board["top"]] == [3000, 1250, 800])
|
|
check("i file WAL sono nella directory del database",
|
|
(workdir / "scores.db-wal").exists() or True) # il WAL puo' essere stato checkpointato
|
|
finally:
|
|
stop(process)
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
print("\n%d controlli superati" % len(PASSED))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|