diff --git a/src/apps/dashboard/tests/integrated/test_wallet_views.py b/src/apps/dashboard/tests/integrated/test_wallet_views.py index b14724e..abe6827 100644 --- a/src/apps/dashboard/tests/integrated/test_wallet_views.py +++ b/src/apps/dashboard/tests/integrated/test_wallet_views.py @@ -48,6 +48,39 @@ class WalletViewTest(TestCase): self.assertGreater(len(bundles), 0) +class WalletPassTokenVisibilityTest(TestCase): + """PASS is admin-only — the model guard blocks bogus rows from existing + for non-staff users, but defend the wallet surface too so a future + code path that bypasses the model (eg. raw SQL backfill) doesn't + silently leak the trinket into a non-admin's view.""" + + def test_pass_token_in_context_for_staff(self): + user = User.objects.create(email="staff@test.io", is_staff=True) + self.client.force_login(user) + response = self.client.get("/dashboard/wallet/") + self.assertIsNotNone(response.context["pass_token"]) + + def test_pass_token_absent_for_non_staff(self): + user = User.objects.create(email="reg@test.io") + self.client.force_login(user) + response = self.client.get("/dashboard/wallet/") + self.assertIsNone(response.context["pass_token"]) + + def test_pass_token_absent_in_htmx_toggle_partial_for_non_staff(self): + Applet.objects.get_or_create( + slug="wallet-tokens", + defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"}, + ) + user = User.objects.create(email="reg2@test.io") + self.client.force_login(user) + response = self.client.post( + "/dashboard/wallet/toggle-applets", + {"applets": ["wallet-tokens"]}, + HTTP_HX_REQUEST="true", + ) + self.assertIsNone(response.context["pass_token"]) + + class WalletViewAppletContextTest(TestCase): def setUp(self): self.user = User.objects.create(email="walletctx@test.io") diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 9881e7c..8818bc7 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -164,7 +164,7 @@ def wallet(request): tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE)) return render(request, "apps/dashboard/wallet.html", { "wallet": request.user.wallet, - "pass_token": request.user.tokens.filter(token_type=Token.PASS).first(), + "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "coin": request.user.tokens.filter(token_type=Token.COIN).first(), "free_tokens": free_tokens, "tithe_tokens": tithe_tokens, @@ -200,7 +200,7 @@ def toggle_wallet_applets(request): return render(request, "apps/wallet/_partials/_applets.html", { "applets": applet_context(request.user, "wallet"), "wallet": request.user.wallet, - "pass_token": request.user.tokens.filter(token_type=Token.PASS).first(), + "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "coin": request.user.tokens.filter(token_type=Token.COIN).first(), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index adfd1fa..6c7a476 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1598,6 +1598,8 @@ class MySeaPaidDrawViewTest(TestCase): def test_paid_draw_with_pass_does_not_consume(self): from apps.lyric.models import Token + self.user.is_staff = True + self.user.save(update_fields=["is_staff"]) pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS) self.draw.deposit_token_id = pass_tok.pk self.draw.save(update_fields=["deposit_token_id"]) @@ -1718,6 +1720,8 @@ class DebitMySeaTokenTest(TestCase): def test_pass_token_is_not_consumed(self): from apps.lyric.models import Token from apps.gameboard.models import debit_my_sea_token + self.user.is_staff = True + self.user.save(update_fields=["is_staff"]) pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS) debit_my_sea_token(self.user, pass_tok) self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists()) diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 9125445..bf5ef54 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -2,6 +2,7 @@ import uuid from datetime import timedelta from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager +from django.core.exceptions import ValidationError from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver @@ -250,6 +251,25 @@ class Token(models.Model): next_ready_at = models.DateTimeField(null=True, blank=True) slots_claimed = models.PositiveSmallIntegerField(default=0, blank=True) + def clean(self): + # PASS is admin-only — game-side surfaces (gameboard, game-kit, gate + # picker) all filter PASS behind user.is_staff, so a non-staff PASS + # row is invisible/unusable and just clutters the wallet. A non-admin + # variant ("Boost Pass" or similar) will land as a distinct token_type + # later — keep the rule strict here so the two never blur. + super().clean() + if self.token_type == self.PASS and self.user_id and not self.user.is_staff: + raise ValidationError( + {"token_type": "PASS is admin-only — staff users only."} + ) + + def save(self, *args, **kwargs): + if self.token_type == self.PASS and self.user_id and not self.user.is_staff: + raise ValidationError( + {"token_type": "PASS is admin-only — staff users only."} + ) + super().save(*args, **kwargs) + def tooltip_name(self): return self.get_token_type_display() diff --git a/src/apps/lyric/tests/integrated/test_admin.py b/src/apps/lyric/tests/integrated/test_admin.py index 54cc26c..8d22176 100644 --- a/src/apps/lyric/tests/integrated/test_admin.py +++ b/src/apps/lyric/tests/integrated/test_admin.py @@ -48,7 +48,24 @@ class TokenAdminFormTest(TestCase): self.assertTrue(form.is_valid()) def test_other_token_types_do_not_require_expires_at(self): - for token_type in (Token.COIN, Token.TITHE, Token.PASS): + for token_type in (Token.COIN, Token.TITHE): with self.subTest(token_type=token_type): form = self._form(token_type) self.assertTrue(form.is_valid()) + + def test_pass_token_for_non_staff_user_is_invalid(self): + # PASS is the admin-only trinket — the admin form must surface this + # as a validation error instead of silently writing a row that the + # game-side surfaces (game kit, gate picker) will then ignore. + form = self._form(Token.PASS) + self.assertFalse(form.is_valid()) + + def test_pass_token_for_staff_user_is_valid(self): + staff = User.objects.create(email="staffadmin@example.com", is_staff=True) + Token.objects.filter(user=staff, token_type=Token.PASS).delete() + form = TokenAdminForm(data={ + "user": staff.pk, + "token_type": Token.PASS, + "expires_at": "", + }) + self.assertTrue(form.is_valid()) diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index 05794e2..024d82f 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -334,6 +334,32 @@ class CarteTokenCreationTest(TestCase): self.assertIsNone(token.expires_at) +class PassTokenStaffOnlyGuardTest(TestCase): + """PASS is the admin-only trinket. Creating one for a non-staff user + must raise — guard the rule at the model layer so admin UI, shell, + and any future programmatic path all enforce it. See [[feedback-pass-token-staff-only]] + for the broader design (a non-admin variant lands as a distinct token later).""" + + def test_create_pass_for_non_staff_raises(self): + from django.core.exceptions import ValidationError + non_staff = User.objects.create(email="gamer@test.io") + with self.assertRaises(ValidationError): + Token.objects.create(user=non_staff, token_type=Token.PASS) + + def test_create_pass_for_staff_succeeds(self): + staff = User.objects.create(email="admin@test.io", is_staff=True) + # superuser auto-grants a PASS in the post_save signal; delete it so + # we can assert the explicit-create path works without UNIQUE issues. + Token.objects.filter(user=staff, token_type=Token.PASS).delete() + token = Token.objects.create(user=staff, token_type=Token.PASS) + self.assertEqual(token.token_type, Token.PASS) + + def test_non_pass_token_for_non_staff_still_allowed(self): + non_staff = User.objects.create(email="ok@test.io") + token = Token.objects.create(user=non_staff, token_type=Token.TITHE) + self.assertEqual(token.token_type, Token.TITHE) + + class EquippedDeckTest(TestCase): def test_new_user_gets_earthman_as_default_deck(self): from apps.epic.models import DeckVariant