Skip to content

Commit 403fd97

Browse files
committed
Add tests for auth module
1 parent 851b700 commit 403fd97

10 files changed

Lines changed: 304 additions & 13 deletions

File tree

.github/workflows/workflow.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ jobs:
1010
tests:
1111
runs-on: ubuntu-latest
1212

13+
env:
14+
DB_HOST: ${{ secrets.DB_HOST }}
15+
DB_PORT: ${{ secrets.DB_PORT }}
16+
DB_NAME: ${{ secrets.DB_NAME }}
17+
DB_USER: ${{ secrets.DB_USER }}
18+
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
19+
SECRET_KEY: ${{ secrets.SECRET_KEY }}
20+
SECRET_REFRESH_KEY: ${{ secrets.SECRET_REFRESH_KEY }}
21+
1322
steps:
1423
- uses: actions/checkout@v3
1524

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ dependencies = [
1111
"black>=25.1.0",
1212
"fastapi>=0.115.12",
1313
"freezegun>=1.5.1",
14+
"httpx>=0.28.1",
1415
"isort>=6.0.1",
1516
"passlib[argon2]>=1.7.4",
1617
"pydantic>=2.11.4",

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22
pythonpath = . src
33
asyncio_mode = auto
44
asyncio_default_fixture_loop_scope = function
5+
filterwarnings =
6+
ignore::DeprecationWarning:passlib.*
7+
ignore::matplotlib.MatplotlibDeprecationWarning

requirements.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@ passlib[argon2]>=1.7.4
1414
pyjwt>=2.10.1
1515
whisperx>=3.3.1
1616
python-multipart>=0.0.12
17-
freezegun>=1.5.1
1817
pytest-asyncio>=0.26.0
18+
httpx>=0.28.1

requirements.txt

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ annotated-types==0.7.0
2121
antlr4-python3-runtime==4.9.3
2222
# via omegaconf
2323
anyio==4.9.0
24-
# via starlette
24+
# via
25+
# httpx
26+
# starlette
2527
argon2-cffi==23.1.0
2628
# via passlib
2729
argon2-cffi-bindings==21.2.0
@@ -37,7 +39,10 @@ av==14.4.0
3739
black==25.1.0
3840
# via -r requirements.in
3941
certifi==2025.4.26
40-
# via requests
42+
# via
43+
# httpcore
44+
# httpx
45+
# requests
4146
cffi==1.17.1
4247
# via
4348
# argon2-cffi-bindings
@@ -88,8 +93,6 @@ flatbuffers==25.2.10
8893
# via onnxruntime
8994
fonttools==4.58.0
9095
# via matplotlib
91-
freezegun==1.5.1
92-
# via -r requirements.in
9396
frozenlist==1.6.0
9497
# via
9598
# aiohttp
@@ -105,7 +108,13 @@ greenlet==3.2.2
105108
# advanced-alchemy
106109
# sqlalchemy
107110
h11==0.16.0
108-
# via uvicorn
111+
# via
112+
# httpcore
113+
# uvicorn
114+
httpcore==1.0.9
115+
# via httpx
116+
httpx==0.28.1
117+
# via -r requirements.in
109118
huggingface-hub==0.31.4
110119
# via
111120
# faster-whisper
@@ -120,6 +129,7 @@ hyperpyyaml==1.2.2
120129
idna==3.10
121130
# via
122131
# anyio
132+
# httpx
123133
# requests
124134
# yarl
125135
iniconfig==2.1.0
@@ -281,7 +291,6 @@ pytest-asyncio==0.26.0
281291
# via -r requirements.in
282292
python-dateutil==2.9.0.post0
283293
# via
284-
# freezegun
285294
# matplotlib
286295
# pandas
287296
python-dotenv==1.1.0

src/config.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ class Settings(BaseSettings):
1212

1313
SECRET_KEY: str
1414
SECRET_REFRESH_KEY: str
15-
JWT_ALGORITHM: str
16-
ACCESS_TOKEN_EXPIRE_MINUTES: int
17-
REFRESH_TOKEN_EXPIRE_DAYS: int
15+
JWT_ALGORITHM: str = "HS256"
16+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
17+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
1818

19-
DEVICE: str
20-
COMPUTE_TYPE: str
21-
DOWNLOAD_ROOT: str
19+
DEVICE: str = "cpu"
20+
COMPUTE_TYPE: str = "float32"
21+
DOWNLOAD_ROOT: str = "models"
2222

2323
@property
2424
def DB_URL(self) -> str:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from src.auth.security.passwords import hash_password, verify_password
2+
3+
4+
def test_hash_password_returns_string():
5+
"""Test that hash_password returns a non-empty string."""
6+
password = "test_password"
7+
hashed = hash_password(password)
8+
9+
assert isinstance(hashed, str)
10+
assert len(hashed) > 0
11+
12+
13+
def test_hash_password_generates_different_hashes():
14+
"""Test that same password generates different hashes (salt verification)."""
15+
password = "test_password"
16+
hash1 = hash_password(password)
17+
hash2 = hash_password(password)
18+
19+
assert hash1 != hash2
20+
21+
22+
def test_verify_password_correct():
23+
"""Test that verify_password returns True for correct password."""
24+
password = "test_password"
25+
hashed = hash_password(password)
26+
27+
assert verify_password(password, hashed) is True
28+
29+
30+
def test_verify_password_incorrect():
31+
"""Test that verify_password returns False for incorrect password."""
32+
password = "test_password"
33+
hashed = hash_password(password)
34+
35+
assert verify_password("wrong_password", hashed) is False
36+
37+
38+
def test_hash_format_is_argon2():
39+
"""Test that hash uses Argon2 format."""
40+
hashed = hash_password("test_password")
41+
assert hashed.startswith("$argon2")

tests/auth/security/test_token.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from datetime import timedelta
2+
from uuid import uuid4
3+
4+
import jwt
5+
import pytest
6+
from fastapi import HTTPException
7+
8+
from src.auth.security.schemas import TokenPayload
9+
from src.auth.security.token import (
10+
create_access_token,
11+
create_refresh_token,
12+
create_token,
13+
get_data_from_payload,
14+
parse_token_payload,
15+
verify_refresh_token,
16+
verify_token,
17+
)
18+
from src.config import settings
19+
from src.users.models import Role
20+
21+
22+
@pytest.fixture
23+
def token_payload():
24+
"""Fixture for generating a sample TokenPayload with UUID and role."""
25+
return TokenPayload(id=uuid4(), role=Role.USER)
26+
27+
28+
def test_create_token_returns_valid_jwt(token_payload):
29+
"""Test that create_token returns a valid JWT with expected payload and exp."""
30+
data = {"id": str(token_payload.id), "role": token_payload.role.value}
31+
token = create_token(data, settings.SECRET_KEY, timedelta(minutes=5))
32+
33+
decoded = jwt.decode(
34+
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
35+
)
36+
assert decoded["id"] == str(token_payload.id)
37+
assert decoded["role"] == token_payload.role.value
38+
assert "exp" in decoded
39+
40+
41+
def test_get_data_from_payload(token_payload):
42+
"""Test that get_data_from_payload returns correct dictionary from TokenPayload."""
43+
result = get_data_from_payload(token_payload)
44+
assert result["id"] == str(token_payload.id)
45+
assert result["role"] == token_payload.role.value
46+
47+
48+
def test_create_access_token_returns_string(token_payload):
49+
"""Test that create_access_token returns a string."""
50+
token = create_access_token(token_payload)
51+
assert isinstance(token, str)
52+
53+
54+
def test_create_refresh_token_returns_string(token_payload):
55+
"""Test that create_refresh_token returns a string."""
56+
token = create_refresh_token(token_payload)
57+
assert isinstance(token, str)
58+
59+
60+
def test_parse_token_payload_valid(token_payload):
61+
"""Test that parse_token_payload returns TokenPayload when input is valid."""
62+
payload = {"id": str(token_payload.id), "role": token_payload.role.value}
63+
result = parse_token_payload(payload, "error")
64+
assert result.id == token_payload.id
65+
assert result.role == token_payload.role
66+
67+
68+
@pytest.mark.parametrize(
69+
"payload",
70+
[
71+
{"id": None, "role": "user"},
72+
{"id": "123", "role": None},
73+
{"id": "123", "role": "notarole"},
74+
],
75+
)
76+
def test_parse_token_payload_invalid(payload):
77+
"""Test that parse_token_payload raises HTTPException on invalid or missing fields."""
78+
with pytest.raises(HTTPException):
79+
parse_token_payload(payload, "invalid token")
80+
81+
82+
def test_verify_token_valid(token_payload):
83+
"""Test that verify_token returns valid TokenPayload when token is correct."""
84+
token = create_access_token(token_payload)
85+
result = verify_token(token)
86+
assert result.id == token_payload.id
87+
assert result.role == token_payload.role
88+
89+
90+
def test_verify_refresh_token_valid(token_payload):
91+
"""Test that verify_refresh_token returns valid TokenPayload when token is correct."""
92+
token = create_refresh_token(token_payload)
93+
result = verify_refresh_token(token)
94+
assert result.id == token_payload.id
95+
assert result.role == token_payload.role
96+
97+
98+
def test_verify_token_expired(token_payload):
99+
"""Test that verify_token raises HTTPException on expired token."""
100+
token = create_token(
101+
{"id": str(token_payload.id), "role": token_payload.role.value},
102+
settings.SECRET_KEY,
103+
timedelta(seconds=-1), # already expired
104+
)
105+
with pytest.raises(HTTPException) as exc:
106+
verify_token(token)
107+
assert "expired" in str(exc.value.detail).lower()
108+
109+
110+
def test_verify_token_invalid():
111+
"""Test that verify_token raises HTTPException on completely invalid token."""
112+
invalid_token = "this.is.not.valid"
113+
with pytest.raises(HTTPException) as exc:
114+
verify_token(invalid_token)
115+
assert "invalid" in str(exc.value.detail).lower()

tests/auth/test_routes.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import uuid
2+
3+
import pytest
4+
from httpx import ASGITransport, AsyncClient
5+
6+
from src.auth.security.passwords import hash_password
7+
from src.main import app
8+
from src.users.dependencies import provide_user_service
9+
from src.users.models import Role
10+
11+
12+
@pytest.fixture
13+
async def client():
14+
async with AsyncClient(
15+
transport=ASGITransport(app=app), base_url="http://test"
16+
) as ac:
17+
yield ac
18+
19+
20+
class MockUser:
21+
def __init__(self):
22+
self.id = uuid.uuid4()
23+
self.username = "user"
24+
self.password = hash_password("password")
25+
self.role = Role.USER
26+
27+
28+
class MockUserService:
29+
async def get_one_or_none(self, username: str):
30+
if username == "user":
31+
return MockUser()
32+
return None
33+
34+
35+
@pytest.fixture(autouse=True)
36+
def override_user_service():
37+
app.dependency_overrides[provide_user_service] = lambda: MockUserService()
38+
yield
39+
app.dependency_overrides.clear()
40+
41+
42+
@pytest.fixture
43+
def credentials():
44+
return {"username": "user", "password": "password"}
45+
46+
47+
@pytest.mark.parametrize(
48+
"credentials, expected_status",
49+
[
50+
({"username": "user", "password": "incorrect"}, 401),
51+
({"username": "non-existent-user", "password": "password"}, 401),
52+
({"username": "non-existent-user", "password": "wrong"}, 401),
53+
],
54+
)
55+
@pytest.mark.asyncio
56+
async def test_login_failure_cases(credentials, expected_status, client):
57+
"""Test login failure with various invalid credentials"""
58+
response = await client.post("/auth/login", json=credentials)
59+
assert response.status_code == expected_status
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_login_token_structure(credentials, client):
64+
response = await client.post("/auth/login", json=credentials)
65+
data = response.json()
66+
67+
assert response.status_code == 200
68+
assert "access_token" in data
69+
assert "refresh_token" in data
70+
assert "token_type" in data
71+
assert data["token_type"].lower() == "bearer"
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_login_invalid_json(client):
76+
response = await client.post("/auth/login", json={"wrong": "data"})
77+
assert response.status_code == 422
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_login_no_payload(client):
82+
response = await client.post("/auth/login")
83+
assert response.status_code == 422

0 commit comments

Comments
 (0)