| """ |
| Authentication flow security tests (SECU-01). |
| |
| Tests cover: |
| - User signup with validation |
| - Login with valid/invalid credentials |
| - Logout and session cleanup |
| - Session management |
| - Password security |
| """ |
| import pytest |
| from datetime import timedelta |
| from fastapi.testclient import TestClient |
| from freezegun import freeze_time |
| from sqlalchemy.orm import Session |
| from tests.factories.user_factory import UserFactory |
| from core.auth import get_password_hash, verify_password |
|
|
|
|
| class TestUserSignup: |
| """Test user signup flow.""" |
|
|
| def test_signup_with_valid_data(self, client: TestClient): |
| """Test signup with valid email and password.""" |
| response = client.post("/api/auth/register", json={ |
| "email": "newuser@example.com", |
| "password": "SecurePass123!", |
| "first_name": "New", |
| "last_name": "User" |
| }) |
|
|
| |
| assert response.status_code in [201, 200, 202] |
|
|
| if response.status_code in [201, 200]: |
| data = response.json() |
| |
| assert "data" in data or "user_id" in data or "email" in data |
|
|
| def test_signup_rejects_invalid_email(self, client: TestClient): |
| """Test signup rejects invalid email format.""" |
| response = client.post("/api/auth/register", json={ |
| "email": "not-an-email", |
| "password": "SecurePass123!", |
| "first_name": "Test", |
| "last_name": "User" |
| }) |
|
|
| assert response.status_code in [400, 422] |
|
|
| def test_signup_rejects_weak_password(self, client: TestClient): |
| """Test signup rejects weak passwords.""" |
| weak_passwords = ["123", "password", "abc"] |
|
|
| for password in weak_passwords: |
| response = client.post("/api/auth/register", json={ |
| "email": f"test{password}@example.com", |
| "password": password, |
| "first_name": "Test", |
| "last_name": "User" |
| }) |
|
|
| |
| assert response.status_code in [400, 422] |
|
|
| def test_signup_rejects_duplicate_email(self, client: TestClient, db_session: Session): |
| """Test signup rejects email already in use.""" |
| existing = UserFactory(email="duplicate@example.com") |
| db_session.add(existing) |
| db_session.commit() |
|
|
| response = client.post("/api/auth/register", json={ |
| "email": "duplicate@example.com", |
| "password": "SecurePass123!", |
| "first_name": "Test", |
| "last_name": "User" |
| }) |
|
|
| assert response.status_code in [400, 409, 422] |
|
|
|
|
| class TestUserLogin: |
| """Test user login flow.""" |
|
|
| def test_login_with_valid_credentials(self, client: TestClient, test_user_with_password): |
| """Test login returns JWT token for valid credentials.""" |
| response = client.post("/api/auth/login", json={ |
| "username": test_user_with_password.email, |
| "password": "KnownPassword123!" |
| }) |
|
|
| |
| assert response.status_code == 200 |
|
|
| data = response.json() |
| |
| assert "access_token" in data or "token" in data |
| assert data.get("token_type") == "bearer" |
|
|
| def test_login_rejects_wrong_password(self, client: TestClient, test_user_with_password): |
| """Test login rejects incorrect password.""" |
| response = client.post("/api/auth/login", json={ |
| "username": test_user_with_password.email, |
| "password": "WrongPassword123!" |
| }) |
|
|
| assert response.status_code == 401 |
| assert "incorrect" in response.json()["detail"].lower() or \ |
| "invalid" in response.json()["detail"].lower() |
|
|
| def test_login_rejects_nonexistent_user(self, client: TestClient): |
| """Test login rejects non-existent user.""" |
| response = client.post("/api/auth/login", json={ |
| "username": "nonexistent@example.com", |
| "password": "AnyPassword123!" |
| }) |
|
|
| assert response.status_code == 401 |
|
|
| def test_login_returns_correct_token_structure(self, client: TestClient, test_user_with_password): |
| """Test login returns properly structured JWT token.""" |
| response = client.post("/api/auth/login", json={ |
| "username": test_user_with_password.email, |
| "password": "KnownPassword123!" |
| }) |
|
|
| data = response.json() |
| token = data.get("access_token") or data.get("token") |
|
|
| |
| assert token is not None |
| parts = token.split(".") |
| assert len(parts) == 3 |
|
|
| def test_login_with_email_as_username(self, client: TestClient, test_user_with_password): |
| """Test login works with email as username.""" |
| response = client.post("/api/auth/login", json={ |
| "username": test_user_with_password.email, |
| "password": "KnownPassword123!" |
| }) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert "access_token" in data or "token" in data |
|
|
|
|
| class TestLogoutAndSession: |
| """Test logout and session management.""" |
|
|
| def test_logout_invalidates_session(self, client: TestClient, valid_auth_token): |
| """Test logout invalidates the current session.""" |
| |
| |
|
|
| |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {valid_auth_token}"} |
| ) |
|
|
| |
| |
| if response.status_code == 200: |
| |
| assert True |
| elif response.status_code == 401: |
| |
| assert True |
|
|
| def test_multiple_sessions_per_user(self, client: TestClient, test_user_with_password): |
| """Test user can have multiple active sessions.""" |
| |
| response1 = client.post("/api/auth/login", json={ |
| "username": test_user_with_password.email, |
| "password": "KnownPassword123!" |
| }) |
|
|
| response2 = client.post("/api/auth/login", json={ |
| "username": test_user_with_password.email, |
| "password": "KnownPassword123!" |
| }) |
|
|
| |
| assert response1.status_code == 200 |
| assert response2.status_code == 200 |
|
|
| |
| token1 = response1.json().get("access_token") or response1.json().get("token") |
| token2 = response2.json().get("access_token") or response2.json().get("token") |
|
|
| |
| if token1: |
| parts = token1.split(".") |
| assert len(parts) == 3 |
| if token2: |
| parts = token2.split(".") |
| assert len(parts) == 3 |
|
|
| def test_session_without_token_fails(self, client: TestClient): |
| """Test requests without token are rejected.""" |
| response = client.get("/api/auth/me") |
|
|
| |
| assert response.status_code == 401 |
|
|
|
|
| class TestPasswordSecurity: |
| """Test password hashing and security.""" |
|
|
| def test_password_hashing_uses_bcrypt(self): |
| """Test passwords are hashed using bcrypt.""" |
| password = "TestPassword123!" |
| hashed = get_password_hash(password) |
|
|
| |
| assert hashed.startswith("$2") |
|
|
| |
| assert hashed != password |
|
|
| def test_password_verify_works(self): |
| """Test password verification against hash.""" |
| password = "TestPassword123!" |
| hashed = get_password_hash(password) |
|
|
| assert verify_password(password, hashed) is True |
| assert verify_password("WrongPassword", hashed) is False |
|
|
| def test_password_truncation_at_72_bytes(self): |
| """Test bcrypt truncates passwords at 72 bytes (security invariant).""" |
| |
| long_password = "a" * 100 |
| hashed = get_password_hash(long_password) |
|
|
| |
| assert verify_password(long_password, hashed) is True |
|
|
| |
| |
| longer_password = "a" * 150 |
| assert verify_password(longer_password, hashed) is True |
|
|
| def test_password_hash_is_deterministic_for_same_password(self): |
| """Test password hashing generates different hashes for same password (salt).""" |
| password = "SamePassword123!" |
| hash1 = get_password_hash(password) |
| hash2 = get_password_hash(password) |
|
|
| |
| assert hash1 != hash2 |
|
|
| |
| assert verify_password(password, hash1) is True |
| assert verify_password(password, hash2) is True |
|
|
| def test_empty_password_hashing(self): |
| """Test empty password handling.""" |
| empty_password = "" |
| hashed = get_password_hash(empty_password) |
|
|
| |
| assert hashed.startswith("$2") |
| assert len(hashed) > 50 |
|
|
|
|
| class TestTokenValidation: |
| """Test token validation in auth flows.""" |
|
|
| def test_valid_token_allows_access(self, client: TestClient, valid_auth_token): |
| """Test valid token allows access to protected endpoints.""" |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {valid_auth_token}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 401] |
|
|
| def test_expired_token_denied_access(self, client: TestClient, expired_auth_token): |
| """Test expired token is denied.""" |
| with freeze_time("2026-02-01 12:00:00"): |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {expired_auth_token}"} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
| def test_malformed_token_denied_access(self, client: TestClient): |
| """Test malformed token is denied.""" |
| response = client.get("/api/auth/me", |
| headers={"Authorization": "Bearer invalid.jwt.token"} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
| def test_missing_authorization_header_denied(self, client: TestClient): |
| """Test missing authorization header is denied.""" |
| response = client.get("/api/auth/me") |
|
|
| assert response.status_code == 401 |
|
|