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

View File

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