feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
169
backend/tests/unit/test_auth.py
Normal file
169
backend/tests/unit/test_auth.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Unit tests for the JWT authentication service.
|
||||
|
||||
Tests cover:
|
||||
- Password hashing and verification (bcrypt)
|
||||
- JWT access token creation and validation
|
||||
- JWT refresh token creation and validation
|
||||
- Token rejection for wrong type, expired, invalid, missing subject
|
||||
|
||||
These are pure function tests -- no database or async required.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from jose import jwt
|
||||
|
||||
from app.services.auth import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
verify_token,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Tests for bcrypt password hashing."""
|
||||
|
||||
def test_hash_returns_different_string(self):
|
||||
password = "test-password-123!"
|
||||
hashed = hash_password(password)
|
||||
assert hashed != password
|
||||
|
||||
def test_hash_verify_roundtrip(self):
|
||||
password = "test-password-123!"
|
||||
hashed = hash_password(password)
|
||||
assert verify_password(password, hashed) is True
|
||||
|
||||
def test_verify_rejects_wrong_password(self):
|
||||
hashed = hash_password("correct-password")
|
||||
assert verify_password("wrong-password", hashed) is False
|
||||
|
||||
def test_hash_uses_unique_salts(self):
|
||||
"""Each hash should be different even for the same password (random salt)."""
|
||||
hash1 = hash_password("same-password")
|
||||
hash2 = hash_password("same-password")
|
||||
assert hash1 != hash2
|
||||
|
||||
def test_verify_both_hashes_valid(self):
|
||||
"""Both unique hashes should verify against the original password."""
|
||||
password = "same-password"
|
||||
hash1 = hash_password(password)
|
||||
hash2 = hash_password(password)
|
||||
assert verify_password(password, hash1) is True
|
||||
assert verify_password(password, hash2) is True
|
||||
|
||||
|
||||
class TestAccessToken:
|
||||
"""Tests for JWT access token creation and validation."""
|
||||
|
||||
def test_create_and_verify_roundtrip(self):
|
||||
user_id = uuid.uuid4()
|
||||
tenant_id = uuid.uuid4()
|
||||
token = create_access_token(user_id=user_id, tenant_id=tenant_id, role="admin")
|
||||
payload = verify_token(token, expected_type="access")
|
||||
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["tenant_id"] == str(tenant_id)
|
||||
assert payload["role"] == "admin"
|
||||
assert payload["type"] == "access"
|
||||
|
||||
def test_super_admin_null_tenant(self):
|
||||
user_id = uuid.uuid4()
|
||||
token = create_access_token(user_id=user_id, tenant_id=None, role="super_admin")
|
||||
payload = verify_token(token, expected_type="access")
|
||||
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["tenant_id"] is None
|
||||
assert payload["role"] == "super_admin"
|
||||
|
||||
def test_contains_expiry(self):
|
||||
token = create_access_token(
|
||||
user_id=uuid.uuid4(), tenant_id=uuid.uuid4(), role="viewer"
|
||||
)
|
||||
payload = verify_token(token, expected_type="access")
|
||||
assert "exp" in payload
|
||||
assert "iat" in payload
|
||||
|
||||
|
||||
class TestRefreshToken:
|
||||
"""Tests for JWT refresh token creation and validation."""
|
||||
|
||||
def test_create_and_verify_roundtrip(self):
|
||||
user_id = uuid.uuid4()
|
||||
token = create_refresh_token(user_id=user_id)
|
||||
payload = verify_token(token, expected_type="refresh")
|
||||
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["type"] == "refresh"
|
||||
|
||||
def test_refresh_token_has_no_tenant_or_role(self):
|
||||
token = create_refresh_token(user_id=uuid.uuid4())
|
||||
payload = verify_token(token, expected_type="refresh")
|
||||
|
||||
# Refresh tokens intentionally omit tenant_id and role
|
||||
assert "tenant_id" not in payload
|
||||
assert "role" not in payload
|
||||
|
||||
|
||||
class TestTokenRejection:
|
||||
"""Tests for JWT token validation failure cases."""
|
||||
|
||||
def test_rejects_wrong_type(self):
|
||||
"""Access token should not verify as refresh, and vice versa."""
|
||||
access_token = create_access_token(
|
||||
user_id=uuid.uuid4(), tenant_id=uuid.uuid4(), role="admin"
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_token(access_token, expected_type="refresh")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_rejects_expired_token(self):
|
||||
"""Manually craft an expired token and verify it is rejected."""
|
||||
expired_payload = {
|
||||
"sub": str(uuid.uuid4()),
|
||||
"type": "access",
|
||||
"exp": datetime.now(UTC) - timedelta(hours=1),
|
||||
"iat": datetime.now(UTC) - timedelta(hours=2),
|
||||
}
|
||||
expired_token = jwt.encode(
|
||||
expired_payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_token(expired_token, expected_type="access")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_rejects_invalid_token(self):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_token("not-a-valid-jwt", expected_type="access")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_rejects_wrong_signing_key(self):
|
||||
"""Token signed with a different key should be rejected."""
|
||||
payload = {
|
||||
"sub": str(uuid.uuid4()),
|
||||
"type": "access",
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
}
|
||||
wrong_key_token = jwt.encode(payload, "wrong-secret-key", algorithm="HS256")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_token(wrong_key_token, expected_type="access")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_rejects_missing_subject(self):
|
||||
"""Token without 'sub' claim should be rejected."""
|
||||
no_sub_payload = {
|
||||
"type": "access",
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
}
|
||||
no_sub_token = jwt.encode(
|
||||
no_sub_payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_token(no_sub_token, expected_type="access")
|
||||
assert exc_info.value.status_code == 401
|
||||
Reference in New Issue
Block a user