Skip to content

Commit 8907586

Browse files
committed
test: добавить первый блок unit-тестов для security и auth
Что сделано: - добавлена структура tests/unit с разделением по security и services - добавлен tests/conftest.py с bootstrap env и корректным import path для app - добавлены unit-тесты для JWT: roundtrip, тип токена, срок действия, jti - добавлены async unit-тесты для hash/verify пароля и ограничений длины - добавлены unit-тесты сервиса auth с mock/AsyncMock на репозиторий и зависимые функции - добавлена базовая pytest-конфигурация в pyproject.toml (testpaths, asyncio_mode, addopts)
1 parent fb8f2a4 commit 8907586

5 files changed

Lines changed: 267 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,9 @@ dev = [
3535
"pytest-asyncio>=1.3.0",
3636
"ruff>=0.15.5",
3737
]
38+
39+
[tool.pytest.ini_options]
40+
testpaths = ["tests"]
41+
python_files = ["test_*.py"]
42+
asyncio_mode = "auto"
43+
addopts = "-ra"

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
from pathlib import Path
6+
7+
ROOT_DIR = Path(__file__).resolve().parents[1]
8+
if str(ROOT_DIR) not in sys.path:
9+
sys.path.insert(0, str(ROOT_DIR))
10+
11+
# Минимальные обязательные переменные для загрузки app.config в тестах.
12+
os.environ.setdefault(
13+
"DATABASE_URL",
14+
"postgresql+asyncpg://test_user:test_password@localhost:5432/test_db",
15+
)
16+
os.environ.setdefault("SECRET_KEY", "test-secret-key-minimum-32-bytes-value")

tests/unit/security/test_jwt.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timedelta, timezone
4+
5+
import jwt
6+
import pytest
7+
8+
from app.config import settings
9+
from app.security.jwt import (
10+
TokenExpired,
11+
TokenInvalid,
12+
create_access_token,
13+
create_refresh_token,
14+
decode_access_token,
15+
decode_refresh_token,
16+
)
17+
18+
19+
def _unix_now() -> int:
20+
return int(datetime.now(timezone.utc).timestamp())
21+
22+
23+
def test_create_and_decode_access_token_roundtrip() -> None:
24+
token = create_access_token(subject="user-123")
25+
26+
decoded = decode_access_token(token)
27+
28+
assert decoded.sub == "user-123"
29+
assert decoded.payload["type"] == "access"
30+
31+
32+
def test_decode_access_token_rejects_refresh_token() -> None:
33+
refresh_token, _ = create_refresh_token(subject="user-123")
34+
35+
with pytest.raises(TokenInvalid, match="Ожидался access токен"):
36+
decode_access_token(refresh_token)
37+
38+
39+
def test_decode_refresh_token_requires_jti() -> None:
40+
payload = {
41+
"sub": "user-123",
42+
"iat": _unix_now(),
43+
"exp": _unix_now() + 60,
44+
"type": "refresh",
45+
}
46+
token_without_jti = jwt.encode(
47+
payload=payload,
48+
key=settings.SECRET_KEY,
49+
algorithm=settings.ALGORITHM,
50+
)
51+
52+
with pytest.raises(TokenInvalid, match="отсутствует jti"):
53+
decode_refresh_token(token_without_jti)
54+
55+
56+
def test_decode_access_token_raises_for_expired_token() -> None:
57+
expired_token = create_access_token(
58+
subject="user-123",
59+
expires_delta=timedelta(seconds=-1),
60+
)
61+
62+
with pytest.raises(TokenExpired, match="истёк"):
63+
decode_access_token(expired_token)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from app.config import settings
6+
from app.security.password import hash_password, verify_password
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_hash_and_verify_password_roundtrip() -> None:
11+
hashed = await hash_password(password="StrongPass123!")
12+
13+
assert await verify_password(password="StrongPass123!", hashed_password=hashed)
14+
15+
16+
@pytest.mark.asyncio
17+
async def test_verify_password_returns_false_for_invalid_password() -> None:
18+
hashed = await hash_password(password="StrongPass123!")
19+
20+
assert not await verify_password(password="wrong-pass", hashed_password=hashed)
21+
22+
23+
@pytest.mark.asyncio
24+
@pytest.mark.parametrize(
25+
"password",
26+
["x" * (settings.ARGON_MAX_PASSWORD_LEN + 1)],
27+
)
28+
async def test_hash_password_rejects_overlong_password(password: str) -> None:
29+
with pytest.raises(ValueError, match="слишком длинный"):
30+
await hash_password(password=password)
31+
32+
33+
@pytest.mark.asyncio
34+
@pytest.mark.parametrize(
35+
"password",
36+
["x" * (settings.ARGON_MAX_PASSWORD_LEN + 1)],
37+
)
38+
async def test_verify_password_returns_false_for_overlong_password(
39+
password: str,
40+
) -> None:
41+
hashed = await hash_password(password="StrongPass123!")
42+
43+
assert not await verify_password(password=password, hashed_password=hashed)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from __future__ import annotations
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import AsyncMock, Mock
5+
from uuid import uuid4
6+
7+
import pytest
8+
9+
from app.services import auth as auth_service
10+
11+
12+
@pytest.fixture
13+
def fake_session() -> object:
14+
return object()
15+
16+
17+
@pytest.fixture
18+
def active_user() -> SimpleNamespace:
19+
return SimpleNamespace(
20+
id=uuid4(),
21+
email="user@example.com",
22+
hashed_password="hashed-password",
23+
is_active=True,
24+
)
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_register_user_raises_when_email_already_exists(
29+
monkeypatch: pytest.MonkeyPatch,
30+
fake_session: object,
31+
active_user: SimpleNamespace,
32+
) -> None:
33+
get_user_by_email = AsyncMock(return_value=active_user)
34+
create_user = AsyncMock()
35+
36+
monkeypatch.setattr(auth_service.user_repo, "get_user_by_email", get_user_by_email)
37+
monkeypatch.setattr(auth_service.user_repo, "create_user", create_user)
38+
39+
with pytest.raises(auth_service.UserAlreadyExists):
40+
await auth_service.register_user(fake_session, "user@example.com", "password")
41+
42+
create_user.assert_not_awaited()
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_register_user_hashes_password_and_creates_user(
47+
monkeypatch: pytest.MonkeyPatch,
48+
fake_session: object,
49+
active_user: SimpleNamespace,
50+
) -> None:
51+
get_user_by_email = AsyncMock(return_value=None)
52+
hash_password = AsyncMock(return_value="hashed-by-test")
53+
create_user = AsyncMock(return_value=active_user)
54+
55+
monkeypatch.setattr(auth_service.user_repo, "get_user_by_email", get_user_by_email)
56+
monkeypatch.setattr(auth_service, "hash_password", hash_password)
57+
monkeypatch.setattr(auth_service.user_repo, "create_user", create_user)
58+
59+
created = await auth_service.register_user(
60+
fake_session,
61+
"user@example.com",
62+
"password",
63+
)
64+
65+
assert created is active_user
66+
hash_password.assert_awaited_once_with(password="password")
67+
create_user.assert_awaited_once_with(
68+
fake_session,
69+
email="user@example.com",
70+
hashed_password="hashed-by-test",
71+
)
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_authenticate_user_uses_dummy_hash_when_user_not_found(
76+
monkeypatch: pytest.MonkeyPatch,
77+
fake_session: object,
78+
) -> None:
79+
get_user_by_email = AsyncMock(return_value=None)
80+
get_dummy_hash = Mock(return_value="dummy-hash")
81+
verify_password = AsyncMock(return_value=False)
82+
83+
monkeypatch.setattr(auth_service.user_repo, "get_user_by_email", get_user_by_email)
84+
monkeypatch.setattr(auth_service, "get_dummy_hash", get_dummy_hash)
85+
monkeypatch.setattr(auth_service, "verify_password", verify_password)
86+
87+
authenticated = await auth_service.authenticate_user(
88+
fake_session,
89+
"missing@example.com",
90+
"password",
91+
)
92+
93+
assert authenticated is None
94+
verify_password.assert_awaited_once_with(
95+
password="password",
96+
hashed_password="dummy-hash",
97+
)
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_authenticate_active_user_raises_for_inactive_user(
102+
monkeypatch: pytest.MonkeyPatch,
103+
) -> None:
104+
inactive_user = SimpleNamespace(is_active=False)
105+
authenticate_user = AsyncMock(return_value=inactive_user)
106+
107+
monkeypatch.setattr(auth_service, "authenticate_user", authenticate_user)
108+
109+
with pytest.raises(auth_service.UserInactive):
110+
await auth_service.authenticate_active_user(
111+
object(),
112+
"user@example.com",
113+
"password",
114+
)
115+
116+
117+
@pytest.mark.asyncio
118+
async def test_validate_refresh_subject_raises_when_user_not_found(
119+
monkeypatch: pytest.MonkeyPatch,
120+
fake_session: object,
121+
) -> None:
122+
get_user_by_id = AsyncMock(return_value=None)
123+
monkeypatch.setattr(auth_service.user_repo, "get_user_by_id", get_user_by_id)
124+
125+
with pytest.raises(auth_service.UserNotFound):
126+
await auth_service.validate_refresh_subject(fake_session, uuid4())
127+
128+
129+
@pytest.mark.asyncio
130+
async def test_validate_refresh_subject_raises_when_user_inactive(
131+
monkeypatch: pytest.MonkeyPatch,
132+
fake_session: object,
133+
) -> None:
134+
inactive_user = SimpleNamespace(is_active=False)
135+
get_user_by_id = AsyncMock(return_value=inactive_user)
136+
monkeypatch.setattr(auth_service.user_repo, "get_user_by_id", get_user_by_id)
137+
138+
with pytest.raises(auth_service.UserInactive):
139+
await auth_service.validate_refresh_subject(fake_session, uuid4())

0 commit comments

Comments
 (0)