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