- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
6.3 KiB
Python
139 lines
6.3 KiB
Python
"""
|
||
حفاظهای بازبینیِ امنیتیِ پیش از تحویل (دستیار). مدل و Qdrant همه ساختگیاند.
|
||
"""
|
||
import json
|
||
from types import SimpleNamespace
|
||
from unittest.mock import patch
|
||
|
||
from django.core.cache import cache
|
||
from django.test import TestCase, override_settings
|
||
from rest_framework.test import APIClient
|
||
|
||
from accounts.authentication import issue_access_token
|
||
from accounts.models import User
|
||
from ai_tutor.models import Message
|
||
from rag.services.clients import reset_clients_cache
|
||
|
||
RAG_ON = dict(OPENAI_API_KEY='t', QDRANT_API_KEY='t', RAG_FORCE_MOCK=False, AI_DAILY_QUOTA_FREE=3)
|
||
|
||
ANSWER = '۱) دو بهعلاوهی دو میشود چهار.\n✅ جواب: ۴\n'
|
||
OVERFLOW_DIAGRAM = '<diagram>{"kind":"numberline","min":1' + '0' * 400 + '*2,"max":5}</diagram>'
|
||
DEEP_DIAGRAM = '<diagram>' + '[' * 3000 + ']' * 3000 + '</diagram>'
|
||
|
||
|
||
class FakeOpenAI:
|
||
def __init__(self, reply):
|
||
self.reply = reply
|
||
self.calls = 0
|
||
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
|
||
self.embeddings = SimpleNamespace(
|
||
create=lambda **k: SimpleNamespace(data=[SimpleNamespace(embedding=[0.01] * 1536)]))
|
||
|
||
def _create(self, **kw):
|
||
self.calls += 1
|
||
if kw.get('stream'):
|
||
parts = [self.reply[i:i + 25] for i in range(0, len(self.reply), 25)]
|
||
events = [SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=p))], usage=None)
|
||
for p in parts]
|
||
events.append(SimpleNamespace(choices=[], usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300)))
|
||
return iter(events)
|
||
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=self.reply))],
|
||
usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300))
|
||
|
||
|
||
def sse(resp):
|
||
out = []
|
||
for block in b''.join(resp.streaming_content).decode().split('\n\n'):
|
||
ev = data = None
|
||
for line in block.strip().splitlines():
|
||
if line.startswith('event: '):
|
||
ev = line[7:]
|
||
elif line.startswith('data: '):
|
||
data = json.loads(line[6:])
|
||
if ev:
|
||
out.append((ev, data))
|
||
return out
|
||
|
||
|
||
@override_settings(**RAG_ON)
|
||
class _Base(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
reset_clients_cache()
|
||
self.user = User.objects.create(phone='09120009901', is_guest=False)
|
||
self.c = APIClient()
|
||
self.c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||
|
||
def ask(self, q, **extra):
|
||
return self.c.post('/api/ai/ask', {'userQuestion': q, **extra}, format='json')
|
||
|
||
def remaining(self):
|
||
return self.c.get('/api/ai/quota').data['remainingQuota']
|
||
|
||
|
||
class BrokenDiagramDoesNotMakeAnswersFreeTests(_Base):
|
||
"""پارسرِ شکل OverflowError/RecursionError را بالا میداد و جوابِ استریمشده رایگان و بیثبت میماند."""
|
||
|
||
def test_stream_and_json_answers_are_charged(self):
|
||
for diagram in (OVERFLOW_DIAGRAM, DEEP_DIAGRAM):
|
||
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + diagram)):
|
||
events = sse(self.ask(f'دو بهعلاوهی دو؟ {len(diagram)}', stream=True))
|
||
self.assertEqual(events[-1][0], 'done')
|
||
self.assertIsNone(events[-1][1]['diagram'])
|
||
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + OVERFLOW_DIAGRAM)):
|
||
self.assertEqual(self.ask('دو بهعلاوهی دو؟').status_code, 200)
|
||
self.assertEqual(self.remaining(), 0)
|
||
self.assertEqual(Message.objects.filter(role='assistant').count(), 3)
|
||
|
||
|
||
@override_settings(RAG_ANSWER_CACHE_TTL=300)
|
||
class LateOffTopicMarkerTests(_Base):
|
||
def test_rest_is_not_streamed_stored_or_cached(self):
|
||
from rag.services.guardrails import OFF_TOPIC_MARKER, REFUSAL
|
||
|
||
reply = ('۱) این یک جملهی ریاضیِ بلند برای عبور از چهل نویسه است.\n'
|
||
f'{OFF_TOPIC_MARKER} متنِ غیرِ ریاضی: یک انشای کامل دربارهی فوتبال.\n')
|
||
fake = FakeOpenAI(reply)
|
||
q = 'سوال دربارهی چیزی'
|
||
with patch('rag.services.clients.get_openai_client', return_value=fake):
|
||
streamed = ''.join(d['delta'] for e, d in sse(self.ask(q, stream=True)) if e == 'chunk')
|
||
other = User.objects.create(phone='09120009902', is_guest=False)
|
||
c2 = APIClient()
|
||
c2.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(other.id)}')
|
||
second = c2.post('/api/ai/ask', {'userQuestion': q}, format='json')
|
||
self.assertNotIn('فوتبال', streamed)
|
||
self.assertIn(REFUSAL, streamed)
|
||
self.assertEqual(Message.objects.filter(role='assistant').first().text, REFUSAL)
|
||
self.assertNotIn('فوتبال', second.data['answer'])
|
||
self.assertEqual(fake.calls, 2) # جوابِ ردشده در کش نرفت
|
||
|
||
|
||
class DisconnectMidStreamTests(_Base):
|
||
def test_partial_answer_is_saved(self):
|
||
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER * 10)):
|
||
resp = self.ask('سوال', stream=True)
|
||
it = iter(resp.streaming_content)
|
||
next(it)
|
||
next(it)
|
||
resp._iterator.close() # همان GeneratorExit که بستنِ صفحه میسازد
|
||
saved = Message.objects.filter(role='assistant')
|
||
self.assertEqual(saved.count(), 1)
|
||
self.assertTrue(saved.first().text.endswith('…'))
|
||
self.assertEqual(self.remaining(), 2)
|
||
|
||
|
||
class UnknownCollectionIsNotSearchedTests(_Base):
|
||
def test_client_chosen_collection_is_ignored(self):
|
||
seen = []
|
||
|
||
def fake_search(collection, vec, **kw):
|
||
seen.append(collection)
|
||
return [{'score': 0.9, 'payload': {'title': 'doc', 'text': 'PAYLOAD-OF-UNRELATED-COLLECTION'}}]
|
||
|
||
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI('پاسخ')), \
|
||
patch('rag.services.retrieval.search', side_effect=fake_search):
|
||
r = self.ask('هر چیزی', subject='any', grade='other collection')
|
||
self.assertEqual(r.status_code, 200)
|
||
self.assertEqual(seen, [])
|
||
self.assertEqual(r.data['usedChunks'], [])
|