fix: Token.PASS is now model-enforced as staff-only — Token.clean/save raise ValidationError when a non-staff user is the FK target. Staging bug 2026-05-21 — admin awarded a PASS to a non-admin via Django admin; row was created + showed in the user's wallet, but every game-side surface (gameboard, game-kit, gate-pad select_token, _select_my_sea_token) had always filtered PASS behind is_staff, so the token was unequippable + unusable. Five is_staff-gated PASS surfaces made PASS a deliberate staff-only trinket; the wallet was the lone outlier surfacing it. Bundled: wallet view (+ HTMX toggle partial) now gates pass_token behind is_staff mirroring the gameboard pattern — defense-in-depth in case any future bypass writes a stray row. TDD — new ITs: PassTokenStaffOnlyGuardTest (model raises for non-staff, accepts for staff, leaves other token types unaffected); WalletPassTokenVisibilityTest (3 cases pin wallet + HTMX gating); TokenAdminFormTest.test_pass_token_for_non_staff_user_is_invalid + test_pass_token_for_staff_user_is_valid. Adjusted 2 existing tests that incidentally exercised the now-blocked pattern (test_paid_draw_with_pass_does_not_consume, test_pass_token_is_not_consumed — both flip is_staff = True inline before Token.objects.create); dropped PASS from test_other_token_types_do_not_require_expires_at's loop (covered by the new dedicated tests). 1133 IT/UT green. A non-admin "boost-pass" variant lands as a distinct token_type later, NEVER by relaxing the staff gate — captured in [[feedback-pass-token-staff-only]]
Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,39 @@ class WalletViewTest(TestCase):
|
|||||||
self.assertGreater(len(bundles), 0)
|
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):
|
class WalletViewAppletContextTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(email="walletctx@test.io")
|
self.user = User.objects.create(email="walletctx@test.io")
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ def wallet(request):
|
|||||||
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
||||||
return render(request, "apps/dashboard/wallet.html", {
|
return render(request, "apps/dashboard/wallet.html", {
|
||||||
"wallet": 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(),
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
"free_tokens": free_tokens,
|
"free_tokens": free_tokens,
|
||||||
"tithe_tokens": tithe_tokens,
|
"tithe_tokens": tithe_tokens,
|
||||||
@@ -200,7 +200,7 @@ def toggle_wallet_applets(request):
|
|||||||
return render(request, "apps/wallet/_partials/_applets.html", {
|
return render(request, "apps/wallet/_partials/_applets.html", {
|
||||||
"applets": applet_context(request.user, "wallet"),
|
"applets": applet_context(request.user, "wallet"),
|
||||||
"wallet": 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(),
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||||
|
|||||||
@@ -1598,6 +1598,8 @@ class MySeaPaidDrawViewTest(TestCase):
|
|||||||
|
|
||||||
def test_paid_draw_with_pass_does_not_consume(self):
|
def test_paid_draw_with_pass_does_not_consume(self):
|
||||||
from apps.lyric.models import Token
|
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)
|
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||||||
self.draw.deposit_token_id = pass_tok.pk
|
self.draw.deposit_token_id = pass_tok.pk
|
||||||
self.draw.save(update_fields=["deposit_token_id"])
|
self.draw.save(update_fields=["deposit_token_id"])
|
||||||
@@ -1718,6 +1720,8 @@ class DebitMySeaTokenTest(TestCase):
|
|||||||
def test_pass_token_is_not_consumed(self):
|
def test_pass_token_is_not_consumed(self):
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
from apps.gameboard.models import debit_my_sea_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)
|
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||||||
debit_my_sea_token(self.user, pass_tok)
|
debit_my_sea_token(self.user, pass_tok)
|
||||||
self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())
|
self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import uuid
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -250,6 +251,25 @@ class Token(models.Model):
|
|||||||
next_ready_at = models.DateTimeField(null=True, blank=True)
|
next_ready_at = models.DateTimeField(null=True, blank=True)
|
||||||
slots_claimed = models.PositiveSmallIntegerField(default=0, 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):
|
def tooltip_name(self):
|
||||||
return self.get_token_type_display()
|
return self.get_token_type_display()
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,24 @@ class TokenAdminFormTest(TestCase):
|
|||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
def test_other_token_types_do_not_require_expires_at(self):
|
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):
|
with self.subTest(token_type=token_type):
|
||||||
form = self._form(token_type)
|
form = self._form(token_type)
|
||||||
self.assertTrue(form.is_valid())
|
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())
|
||||||
|
|||||||
@@ -334,6 +334,32 @@ class CarteTokenCreationTest(TestCase):
|
|||||||
self.assertIsNone(token.expires_at)
|
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):
|
class EquippedDeckTest(TestCase):
|
||||||
def test_new_user_gets_earthman_as_default_deck(self):
|
def test_new_user_gets_earthman_as_default_deck(self):
|
||||||
from apps.epic.models import DeckVariant
|
from apps.epic.models import DeckVariant
|
||||||
|
|||||||
Reference in New Issue
Block a user