Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- 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>
This commit is contained in:
138
ai_tutor/tests_security_review.py
Normal file
138
ai_tutor/tests_security_review.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
حفاظهای بازبینیِ امنیتیِ پیش از تحویل (دستیار). مدل و 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'], [])
|
||||
@@ -152,7 +152,9 @@ def _is_db_ref_id(value) -> bool:
|
||||
"""
|
||||
if value in (None, ''):
|
||||
return False
|
||||
return str(value).strip().lstrip('-').isdigit()
|
||||
# isascii: '²'.isdigit() هم True است ولی int('²') خطا میدهد.
|
||||
text = str(value).strip().lstrip('-')
|
||||
return text.isascii() and text.isdigit()
|
||||
|
||||
|
||||
def _db_ref_or_none(value):
|
||||
@@ -606,7 +608,7 @@ class AskAIView(APIView):
|
||||
event: error data: {"detail": "..."}
|
||||
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
||||
"""
|
||||
from rag.services import answer_cache
|
||||
from rag.services import answer_cache, guardrails
|
||||
from rag.services.generation import (
|
||||
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
|
||||
)
|
||||
@@ -654,12 +656,18 @@ class AskAIView(APIView):
|
||||
stream_result: dict = {}
|
||||
started = time.monotonic()
|
||||
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
|
||||
for delta in stream_answer(
|
||||
v['userQuestion'], used_chunks or None, effective_grade,
|
||||
history=history, focus=focus, result=stream_result, age=age,
|
||||
):
|
||||
parts.append(delta)
|
||||
yield _sse('chunk', {'delta': delta})
|
||||
try:
|
||||
for delta in stream_answer(
|
||||
v['userQuestion'], used_chunks or None, effective_grade,
|
||||
history=history, focus=focus, result=stream_result, age=age,
|
||||
):
|
||||
parts.append(delta)
|
||||
yield _sse('chunk', {'delta': delta})
|
||||
except GeneratorExit:
|
||||
# کاربر صفحه را وسطِ جواب بست. سوال خرج میماند (مدل هزینهاش را گرفته)، ولی
|
||||
# همان بخشی که ساخته شد ثبت میشود تا گفتگو سوالِ بیجواب نداشته باشد.
|
||||
self._save_partial_answer(conversation, user, v, ''.join(parts))
|
||||
raise
|
||||
provider_status.record_success()
|
||||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||
follow_ups = default_follow_ups()
|
||||
@@ -678,6 +686,7 @@ class AskAIView(APIView):
|
||||
}
|
||||
if stream_result.get('refused'):
|
||||
used_chunks = []
|
||||
answer_text = guardrails.REFUSAL
|
||||
elif cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
@@ -752,6 +761,20 @@ class AskAIView(APIView):
|
||||
response['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _save_partial_answer(conversation, user, v, text):
|
||||
logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
|
||||
user.id, conversation.id, len(text))
|
||||
if not text.strip():
|
||||
return
|
||||
try:
|
||||
Message.objects.create(
|
||||
conversation=conversation, role='assistant', text=text.strip() + ' …',
|
||||
question_id=_db_ref_or_none(v.get('questionId')),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
|
||||
|
||||
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
|
||||
"""پاککردن رزرو فاز ۱ وقتی فاز بعدی شکست میخورد، تا نه پیام یتیم بماند و نه کوتا مصرفشده حساب شود."""
|
||||
from subscription.services import refund_credit
|
||||
|
||||
Reference in New Issue
Block a user