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)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user