F3: beskyt lie-submit med player session token #36

Merged
integrator-bot merged 1 commits from feature/f3-lie-submit-session-token into main 2026-02-27 23:15:26 +01:00
5 changed files with 80 additions and 4 deletions

View File

@@ -104,7 +104,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
### Backlog — Need-to-have / Nice-to-have ### Backlog — Need-to-have / Nice-to-have
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh - [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
- [ ] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload) - [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total) - [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge. - [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
- [ ] (Need-to-have) Rate limiting på join/submit endpoints - [ ] (Need-to-have) Rate limiting på join/submit endpoints

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0.2 on 2026-02-27 22:08
import fupogfakta.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0003_roundquestion_mixed_answers'),
]
operations = [
migrations.AddField(
model_name='player',
name='session_token',
field=models.CharField(db_index=True, default=fupogfakta.models._generate_player_session_token, max_length=64),
),
]

View File

@@ -1,9 +1,15 @@
import secrets
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone from django.utils import timezone
User = get_user_model() User = get_user_model()
def _generate_player_session_token() -> str:
return secrets.token_urlsafe(24)
class Category(models.Model): class Category(models.Model):
name = models.CharField(max_length=120, unique=True) name = models.CharField(max_length=120, unique=True)
@@ -54,6 +60,7 @@ class GameSession(models.Model):
class Player(models.Model): class Player(models.Model):
session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="players") session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="players")
nickname = models.CharField(max_length=40) nickname = models.CharField(max_length=40)
session_token = models.CharField(max_length=64, db_index=True, default=_generate_player_session_token)
score = models.IntegerField(default=0) score = models.IntegerField(default=0)
is_connected = models.BooleanField(default=True) is_connected = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -55,6 +55,8 @@ class LobbyFlowTests(TestCase):
body = response.json() body = response.json()
self.assertEqual(body["session"]["code"], "ABCD23") self.assertEqual(body["session"]["code"], "ABCD23")
self.assertEqual(body["player"]["nickname"], "Luna") self.assertEqual(body["player"]["nickname"], "Luna")
self.assertIn("session_token", body["player"])
self.assertTrue(body["player"]["session_token"])
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists()) self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
def test_join_rejects_duplicate_nickname_case_insensitive(self): def test_join_rejects_duplicate_nickname_case_insensitive(self):
@@ -217,7 +219,7 @@ class LieSubmissionTests(TestCase):
"lobby:submit_lie", "lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id}, kwargs={"code": self.session.code, "round_question_id": round_question.id},
), ),
data={"player_id": self.player.id, "text": "Sydney"}, data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Sydney"},
content_type="application/json", content_type="application/json",
) )
@@ -239,7 +241,7 @@ class LieSubmissionTests(TestCase):
"lobby:submit_lie", "lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id}, kwargs={"code": self.session.code, "round_question_id": round_question.id},
), ),
data={"player_id": self.player.id, "text": "Melbourne"}, data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Melbourne"},
content_type="application/json", content_type="application/json",
) )
@@ -260,13 +262,53 @@ class LieSubmissionTests(TestCase):
"lobby:submit_lie", "lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id}, kwargs={"code": self.session.code, "round_question_id": round_question.id},
), ),
data={"player_id": self.player.id, "text": "Brisbane"}, data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Brisbane"},
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"], "Lie already submitted for this player") self.assertEqual(response.json()["error"], "Lie already submitted for this player")
def test_submit_lie_requires_session_token(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
response = self.client.post(
reverse(
"lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id},
),
data={"player_id": self.player.id, "text": "Sydney"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "session_token is required")
def test_submit_lie_rejects_invalid_session_token(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
response = self.client.post(
reverse(
"lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id},
),
data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"},
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Invalid player session token")
class MixAnswersTests(TestCase): class MixAnswersTests(TestCase):
def setUp(self): def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123") self.host = User.objects.create_user(username="host", password="secret123")

View File

@@ -104,6 +104,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
"player": { "player": {
"id": player.id, "id": player.id,
"nickname": player.nickname, "nickname": player.nickname,
"session_token": player.session_token,
"score": player.score, "score": player.score,
}, },
"session": { "session": {
@@ -296,11 +297,15 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
session_code = code.strip().upper() session_code = code.strip().upper()
player_id = payload.get("player_id") player_id = payload.get("player_id")
session_token = str(payload.get("session_token", "")).strip()
lie_text = str(payload.get("text", "")).strip() lie_text = str(payload.get("text", "")).strip()
if not player_id: if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400) return JsonResponse({"error": "player_id is required"}, status=400)
if not session_token:
return JsonResponse({"error": "session_token is required"}, status=400)
if not lie_text or len(lie_text) > 255: if not lie_text or len(lie_text) > 255:
return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400) return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400)
@@ -317,6 +322,9 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
except Player.DoesNotExist: except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404) return JsonResponse({"error": "Player not found in session"}, status=404)
if player.session_token != session_token:
return JsonResponse({"error": "Invalid player session token"}, status=403)
try: try:
round_question = RoundQuestion.objects.get( round_question = RoundQuestion.objects.get(
pk=round_question_id, pk=round_question_id,