Spaces:
Runtime error
Runtime error
File size: 7,353 Bytes
ffd36e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | """Flow-level QA checks β converted from scripts/qa_flow_checks.py (F33).
The original script drove APIClient against the *configured* DB with no
isolation (manual `.delete()` cleanup + `cache.clear()` on the real cache),
so a crashed run could leave junk users/skills behind and wipe live throttle
state. These now run against the pytest test DB (`@pytest.mark.django_db` β
per-test rollback), and conftest's autouse `_clear_throttle_cache` gives each
throttle test a clean rate-limit bucket. Behavioural coverage is unchanged.
"""
from io import StringIO
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from rest_framework.test import APIClient
from apps.progress.models import UserProgress
from apps.resources.models import Resource
from apps.roles.models import Role, UserTargetRole
from apps.skills.models import Skill, UserSkill
User = get_user_model()
@pytest.fixture
def seeded(db):
"""Full curated seed β skills, roles, resources."""
call_command('seed_initial_skills', stdout=StringIO())
call_command('seed_initial_roles', stdout=StringIO())
call_command('seed_initial_resources', stdout=StringIO())
return True
@pytest.mark.django_db
def test_register_email_casing_collision_and_login():
"""Lowercase register β 201; an upper-case variant of the same address β
400 (case-insensitive uniqueness); login accepts any casing."""
c = APIClient()
r1 = c.post('/api/auth/register/', {
'name': 'QA Casing', 'email': 'qa.casing@test.xx',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r1.status_code == 201, r1.data
r2 = c.post('/api/auth/register/', {
'name': 'QA Casing', 'email': 'QA.Casing@Test.XX',
'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!',
}, format='json')
assert r2.status_code == 400, r2.data
r3 = c.post('/api/auth/login/', {
'email': 'QA.CASING@TEST.XX', 'password': 'StrongPass123!',
}, format='json')
assert r3.status_code == 200, r3.data
@pytest.mark.django_db
def test_cross_user_data_isolation(seeded):
"""User B never sees User A's UserSkill or UserProgress rows."""
ua = User.objects.create_user(username='qa.a@test.xx', email='qa.a@test.xx',
password='StrongPass123!', name='Alice')
ub = User.objects.create_user(username='qa.b@test.xx', email='qa.b@test.xx',
password='StrongPass123!', name='Bob')
role = Role.objects.filter(is_active=True).first()
UserTargetRole.objects.update_or_create(user=ua, role=role, defaults={'is_active': True})
UserTargetRole.objects.update_or_create(user=ub, role=role, defaults={'is_active': True})
skill = Skill.objects.first()
UserSkill.objects.update_or_create(
user=ua, skill=skill,
defaults={'proficiency': 88, 'user_level': 'ADVANCED'},
)
ca = APIClient(); ca.force_authenticate(user=ua)
cb = APIClient(); cb.force_authenticate(user=ub)
a_skills = ca.get('/api/user-skills/').json()
b_skills = cb.get('/api/user-skills/').json()
assert isinstance(a_skills, list) and len(a_skills) >= 1
assert not any(s['skill']['id'] == skill.id for s in b_skills)
res = Resource.objects.first()
UserProgress.objects.update_or_create(
user=ua, resource=res,
defaults={'status': 'IN_PROGRESS', 'progress': 55},
)
a_prog = ca.get('/api/progress/').json()
b_prog = cb.get('/api/progress/').json()
assert any(p.get('id') for p in a_prog)
assert not any(p.get('resource') == res.id for p in b_prog)
@pytest.mark.django_db
def test_target_role_switch_reflected_in_analysis(seeded):
"""Switching the active target role changes the role the gap report is
computed against."""
ua = User.objects.create_user(username='qa.sw@test.xx', email='qa.sw@test.xx',
password='StrongPass123!', name='Switch')
r_old = Role.objects.filter(is_active=True).order_by('id').first()
r_new = (Role.objects.filter(is_active=True)
.exclude(id=r_old.id).order_by('id').first())
UserTargetRole.objects.update_or_create(
user=ua, role=r_old, defaults={'is_active': True})
ca = APIClient(); ca.force_authenticate(user=ua)
gap_old = ca.get('/api/analysis/').json()
ca.post('/api/target-role/', {'role_id': r_new.id}, format='json')
gap_new = ca.get('/api/analysis/').json()
assert gap_old.get('role_id') == r_old.id
assert gap_new.get('role_id') == r_new.id
@pytest.mark.django_db
def test_login_throttle_kicks_in_by_11th_attempt():
"""login scope = 10/minute β the 11th attempt in the window returns 429."""
c = APIClient(REMOTE_ADDR='10.9.9.9')
first_429 = None
for i in range(1, 13):
r = c.post('/api/auth/login/', {
'email': 'doesnotexist@test.xx', 'password': 'wrong',
}, format='json')
if r.status_code == 429:
first_429 = i
break
assert first_429 is not None, 'login never throttled'
assert first_429 <= 11, f'throttled too late (attempt {first_429})'
@pytest.mark.django_db
def test_register_throttle_kicks_in_by_21st_attempt():
"""register scope = 20/hour β the 21st attempt in the window returns 429."""
c = APIClient(REMOTE_ADDR='10.9.9.10')
first_429 = None
for i in range(1, 23):
r = c.post('/api/auth/register/', {
'name': 'x', 'email': f'throttle{i}@t.xx',
'password': 'ShortButOk!9', 'password_confirm': 'ShortButOk!9',
}, format='json')
if r.status_code == 429:
first_429 = i
break
assert first_429 is not None, 'register never throttled'
assert first_429 <= 21, f'throttled too late (attempt {first_429})'
@pytest.mark.django_db
def test_skill_with_script_name_round_trips_as_raw_text():
"""A Skill whose name contains <script> is returned verbatim by
/api/skills/ β the API must not mangle it; React escapes on render."""
user = User.objects.create_user(username='qa.xss@test.xx', email='qa.xss@test.xx',
password='StrongPass123!', name='XSS')
Skill.objects.create(skill_name='<script>alert(1)</script>', category='X',
difficulty_level='BEGINNER')
c = APIClient(); c.force_authenticate(user=user)
body = c.get('/api/skills/').json()
rows = body['results'] if isinstance(body, dict) and 'results' in body else body
assert any('<script>' in (row.get('skill_name') or '') for row in rows)
@pytest.mark.django_db
def test_resources_skill_filter_rejects_injection_string():
"""`?skill=1 OR 1=1` is rejected (400) by the NumberFilter before it can
reach the ORM β no SQL injection surface."""
user = User.objects.create_user(username='qa.inj@test.xx', email='qa.inj@test.xx',
password='StrongPass123!', name='Inj')
c = APIClient(); c.force_authenticate(user=user)
r = c.get('/api/resources/?skill=1%20OR%201=1')
assert r.status_code == 400, r.status_code
|