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]]
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

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:
Disco DeDisco
2026-05-21 00:35:55 -04:00
parent 97a6da28a5
commit 0f60c73f3b
6 changed files with 103 additions and 3 deletions

View File

@@ -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")

View File

@@ -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)),

View File

@@ -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())

View File

@@ -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()

View File

@@ -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())

View File

@@ -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