Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools

- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
  without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
  existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
  with the tests that hold them. Comments describe current behaviour, not history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 21:12:28 +03:30
parent b3f624a512
commit 645baf846b
22 changed files with 584 additions and 193 deletions

View File

@@ -13,7 +13,7 @@ from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from .authentication import issue_access_token
from .models import OTPCode, Profile, Session, User
from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
@@ -286,9 +286,10 @@ class RefreshTokenReplayTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(is_guest=True, device_id='device-refresh-test')
self.raw = 'test-refresh-token-abc123'
self.session = Session.objects.create(
user=self.user,
refresh_token='test-refresh-token-abc123',
refresh_token=hash_refresh_token(self.raw),
device_id='device-refresh-test',
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
@@ -296,19 +297,19 @@ class RefreshTokenReplayTests(TestCase):
def test_valid_refresh_token_succeeds(self):
response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data['token'])
def test_same_refresh_token_rejected_on_second_use(self):
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(first_response.status_code, 200)
second_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(second_response.status_code, 401)
@@ -317,15 +318,15 @@ class RefreshTokenReplayTests(TestCase):
def test_successful_refresh_returns_new_refresh_token(self):
response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
new_refresh_token = response.data['refreshToken']
self.assertTrue(new_refresh_token)
self.assertNotEqual(new_refresh_token, self.session.refresh_token)
self.assertNotEqual(new_refresh_token, self.raw)
self.assertTrue(
Session.objects.filter(
refresh_token=new_refresh_token, user=self.user, revoked=False,
refresh_token=hash_refresh_token(new_refresh_token), user=self.user, revoked=False,
).exists()
)
@@ -761,20 +762,23 @@ class RefreshTokenThrottleTests(TestCase):
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
def _create_session(self, token_suffix):
return Session.objects.create(
raw = f'test-refresh-token-{token_suffix}'
session = Session.objects.create(
user=self.user,
refresh_token=f'test-refresh-token-{token_suffix}',
refresh_token=hash_refresh_token(raw),
device_id='device-refresh-throttle-test',
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
)
session.raw = raw
return session
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
@@ -783,13 +787,13 @@ class RefreshTokenThrottleTests(TestCase):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
extra_session = self._create_session('extra')
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': extra_session.raw}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
@@ -797,7 +801,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
first_session = self._create_session('first')
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': first_session.raw}, format='json',
)
self.assertEqual(first_response.status_code, 200)
@@ -806,7 +810,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch('accounts.views.issue_access_token') as mock_issue_token:
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': blocked_session.raw}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
mock_issue_token.assert_not_called()
@@ -1669,3 +1673,31 @@ class TestLoginPhoneParsingTests(TestCase):
'09120000001:abcdef', '09120000001:1234567'):
with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured):
self._parse(raw)
class RefreshTokenStoredHashedTests(TestCase):
"""دیتابیس، بکاپ یا پنلِ ادمین نباید خودِ توکنِ تمدید را داشته باشد؛ فقط هشش."""
def test_issued_tokens_are_stored_only_as_hash(self):
client = APIClient()
body = client.post('/api/auth/session', {'type': 'guest', 'deviceId': 'hash-dev'}, format='json').json()
raw = body['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists())
renewed = client.post('/api/auth/refresh', {'refreshToken': raw}, format='json').json()['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=renewed).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(renewed), revoked=False).exists())
def test_migration_hashes_existing_rows(self):
from importlib import import_module
from django.apps import apps as global_apps
user = User.objects.create(is_guest=True, device_id='hash-mig')
Session.objects.create(user=user, refresh_token='old-raw-token',
expires_at=timezone.now() + timezone.timedelta(days=1))
import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None)
self.assertEqual(Session.objects.get(user=user).refresh_token, hash_refresh_token('old-raw-token'))
response = APIClient().post('/api/auth/refresh', {'refreshToken': 'old-raw-token'}, format='json')
self.assertEqual(response.status_code, 200)