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