Initial commit: backend scaffold, auth, frontend login
- FastAPI + SQLAlchemy async + Alembic + Postgres backend - Auth: JWT access + rotating refresh tokens, argon2, roles, audit log - React 19 + Vite frontend: login page, protected route, auth context - Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""Тесты паролей, JWT и шифрования секретов. Без БД и без сети."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
ALGORITHM,
|
||||
TokenError,
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
generate_refresh_token,
|
||||
hash_password,
|
||||
hash_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
class TestPasswords:
|
||||
def test_verifies_correct_password(self) -> None:
|
||||
assert verify_password("correct horse battery", hash_password("correct horse battery"))
|
||||
|
||||
def test_rejects_wrong_password(self) -> None:
|
||||
assert not verify_password("wrong", hash_password("correct horse battery"))
|
||||
|
||||
def test_hash_is_salted(self) -> None:
|
||||
"""Одинаковые пароли обязаны давать разные хеши."""
|
||||
assert hash_password("same") != hash_password("same")
|
||||
|
||||
def test_rejects_garbage_hash_without_raising(self) -> None:
|
||||
"""Повреждённый хеш в БД не должен ронять вход с 500."""
|
||||
assert not verify_password("anything", "not-a-valid-argon2-hash")
|
||||
|
||||
|
||||
class TestAccessToken:
|
||||
def test_roundtrip_carries_identity(self) -> None:
|
||||
user_id = uuid.uuid4()
|
||||
payload = decode_access_token(create_access_token(user_id, "cashier"))
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["role"] == "cashier"
|
||||
|
||||
def test_rejects_expired_token(self) -> None:
|
||||
expired = jwt.encode(
|
||||
{
|
||||
"sub": str(uuid.uuid4()),
|
||||
"role": "admin",
|
||||
"type": "access",
|
||||
"exp": datetime.now(UTC) - timedelta(minutes=1),
|
||||
},
|
||||
settings.secret_key,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(TokenError):
|
||||
decode_access_token(expired)
|
||||
|
||||
def test_rejects_token_signed_with_other_key(self) -> None:
|
||||
forged = jwt.encode(
|
||||
{
|
||||
"sub": str(uuid.uuid4()),
|
||||
"role": "admin",
|
||||
"type": "access",
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
},
|
||||
"attacker-key",
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(TokenError):
|
||||
decode_access_token(forged)
|
||||
|
||||
def test_refresh_token_is_not_accepted_as_access(self) -> None:
|
||||
"""Ключевая проверка: подмена типа токена не должна давать доступ."""
|
||||
refresh_shaped = jwt.encode(
|
||||
{
|
||||
"sub": str(uuid.uuid4()),
|
||||
"role": "admin",
|
||||
"type": "refresh",
|
||||
"exp": datetime.now(UTC) + timedelta(days=7),
|
||||
},
|
||||
settings.secret_key,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(TokenError, match="access"):
|
||||
decode_access_token(refresh_shaped)
|
||||
|
||||
|
||||
class TestRefreshToken:
|
||||
def test_tokens_are_unique(self) -> None:
|
||||
assert generate_refresh_token()[0] != generate_refresh_token()[0]
|
||||
|
||||
def test_hash_matches_raw_value(self) -> None:
|
||||
raw, stored = generate_refresh_token()
|
||||
assert hash_refresh_token(raw) == stored
|
||||
|
||||
def test_raw_token_is_not_recoverable_from_hash(self) -> None:
|
||||
raw, stored = generate_refresh_token()
|
||||
assert raw not in stored
|
||||
|
||||
|
||||
class TestCrypto:
|
||||
def test_roundtrip(self) -> None:
|
||||
secret = "license-key-abc-123"
|
||||
assert crypto.decrypt(crypto.encrypt(secret)) == secret
|
||||
|
||||
def test_ciphertext_hides_plaintext(self) -> None:
|
||||
assert "license-key" not in crypto.encrypt("license-key-abc-123")
|
||||
|
||||
def test_tampered_ciphertext_is_rejected(self) -> None:
|
||||
token = crypto.encrypt("license-key-abc-123")
|
||||
tampered = token[:-4] + ("AAAA" if not token.endswith("AAAA") else "BBBB")
|
||||
with pytest.raises(crypto.DecryptionError):
|
||||
crypto.decrypt(tampered)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("", ""), ("abc", "***"), ("abcd", "****"), ("abcdefgh", "****efgh")],
|
||||
)
|
||||
def test_mask(self, value: str, expected: str) -> None:
|
||||
assert crypto.mask(value) == expected
|
||||
Reference in New Issue
Block a user