2026-02-24 14:36:31 +00:00
|
|
|
"""Test API routes"""
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAuthRoutes:
|
|
|
|
|
"""Test authentication routes"""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_register_success(self, client):
|
|
|
|
|
"""Test successful user registration"""
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/register",
|
|
|
|
|
json={
|
|
|
|
|
"email": "newuser@example.com",
|
|
|
|
|
"password": "password123",
|
2026-08-09 11:41:34 +00:00
|
|
|
"confirm_password": "password123",
|
2026-02-24 16:19:15 +00:00
|
|
|
"username": "newuser",
|
|
|
|
|
"first_name": "New",
|
|
|
|
|
"last_name": "User",
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
assert response.status_code == 201
|
|
|
|
|
data = response.get_json()
|
2026-08-09 11:41:34 +00:00
|
|
|
assert data["user"]["email"] == "newuser@example.com"
|
|
|
|
|
assert data["user"]["username"] == "newuser"
|
|
|
|
|
assert "password" not in data["user"]
|
|
|
|
|
assert "password_hash" not in data["user"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_register_password_mismatch(self, client):
|
|
|
|
|
"""Test registration with mismatched passwords"""
|
|
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/register",
|
|
|
|
|
json={
|
|
|
|
|
"email": "mismatch@example.com",
|
|
|
|
|
"password": "password123",
|
|
|
|
|
"confirm_password": "differentpassword",
|
|
|
|
|
"username": "mismatch",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert "do not match" in data["error"].lower()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_register_missing_confirm_password(self, client):
|
|
|
|
|
"""Test registration without confirm_password"""
|
|
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/register", json={"email": "noconfirm@example.com", "password": "password123"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert "confirm_password" in data["error"].lower()
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_register_missing_fields(self, client):
|
|
|
|
|
"""Test registration with missing required fields"""
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/register", json={"email": "newuser@example.com"}
|
|
|
|
|
)
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = response.get_json()
|
2026-02-24 16:19:15 +00:00
|
|
|
assert "error" in data
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_register_duplicate_email(self, client, regular_user):
|
|
|
|
|
"""Test registration with duplicate email"""
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/register",
|
2026-08-09 11:41:34 +00:00
|
|
|
json={
|
|
|
|
|
"email": regular_user.email,
|
|
|
|
|
"password": "password123",
|
|
|
|
|
"confirm_password": "password123",
|
|
|
|
|
},
|
2026-02-24 16:19:15 +00:00
|
|
|
)
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = response.get_json()
|
2026-02-24 16:19:15 +00:00
|
|
|
assert "already exists" in data["error"].lower()
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_login_success(self, client, regular_user):
|
|
|
|
|
"""Test successful login"""
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": regular_user.email, "password": "password123"},
|
|
|
|
|
)
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.get_json()
|
2026-02-24 16:19:15 +00:00
|
|
|
assert "access_token" in data
|
|
|
|
|
assert "refresh_token" in data
|
|
|
|
|
assert data["user"]["email"] == regular_user.email
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
2026-02-24 16:19:15 +00:00
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"email,password,expected_status",
|
|
|
|
|
[
|
|
|
|
|
("wrong@example.com", "password123", 401),
|
|
|
|
|
("user@example.com", "wrongpassword", 401),
|
|
|
|
|
(None, "password123", 400),
|
|
|
|
|
("user@example.com", None, 400),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_login_validation(
|
|
|
|
|
self, client, regular_user, email, password, expected_status
|
|
|
|
|
):
|
2026-02-24 14:36:31 +00:00
|
|
|
"""Test login with various invalid inputs"""
|
|
|
|
|
login_data = {}
|
|
|
|
|
if email is not None:
|
2026-02-24 16:19:15 +00:00
|
|
|
login_data["email"] = email
|
2026-02-24 14:36:31 +00:00
|
|
|
if password is not None:
|
2026-02-24 16:19:15 +00:00
|
|
|
login_data["password"] = password
|
2026-02-24 14:36:31 +00:00
|
|
|
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.post("/api/auth/login", json=login_data)
|
2026-02-24 14:36:31 +00:00
|
|
|
assert response.status_code == expected_status
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_login_inactive_user(self, client, inactive_user):
|
|
|
|
|
"""Test login with inactive user"""
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": inactive_user.email, "password": "password123"},
|
|
|
|
|
)
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
data = response.get_json()
|
2026-08-09 11:41:34 +00:00
|
|
|
assert "locked" in data["error"].lower()
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_get_current_user(self, client, auth_headers, regular_user):
|
|
|
|
|
"""Test getting current user"""
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.get("/api/users/me", headers=auth_headers)
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.get_json()
|
2026-02-24 16:19:15 +00:00
|
|
|
assert data["email"] == regular_user.email
|
2026-02-24 14:36:31 +00:00
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_get_current_user_unauthorized(self, client):
|
|
|
|
|
"""Test getting current user without authentication"""
|
2026-02-24 16:19:15 +00:00
|
|
|
response = client.get("/api/users/me")
|
2026-02-24 14:36:31 +00:00
|
|
|
assert response.status_code == 401
|
2026-08-09 11:41:34 +00:00
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_change_password_success(self, client, auth_headers):
|
|
|
|
|
"""Test successful password change"""
|
|
|
|
|
response = client.put(
|
|
|
|
|
"/api/users/me/password",
|
|
|
|
|
headers=auth_headers,
|
|
|
|
|
json={
|
|
|
|
|
"current_password": "password123",
|
|
|
|
|
"new_password": "newpassword456",
|
|
|
|
|
"confirm_password": "newpassword456",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert "Password updated" in data["message"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_change_password_wrong_current(self, client, auth_headers):
|
|
|
|
|
"""Test password change with wrong current password"""
|
|
|
|
|
response = client.put(
|
|
|
|
|
"/api/users/me/password",
|
|
|
|
|
headers=auth_headers,
|
|
|
|
|
json={
|
|
|
|
|
"current_password": "wrongpassword",
|
|
|
|
|
"new_password": "newpassword456",
|
|
|
|
|
"confirm_password": "newpassword456",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert "incorrect" in data["error"].lower()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_change_password_mismatch(self, client, auth_headers):
|
|
|
|
|
"""Test password change with mismatched new passwords"""
|
|
|
|
|
response = client.put(
|
|
|
|
|
"/api/users/me/password",
|
|
|
|
|
headers=auth_headers,
|
|
|
|
|
json={
|
|
|
|
|
"current_password": "password123",
|
|
|
|
|
"new_password": "newpassword456",
|
|
|
|
|
"confirm_password": "different456",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert "do not match" in data["error"].lower()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_get_login_history(self, client, auth_headers, regular_user):
|
|
|
|
|
"""Test getting login history"""
|
|
|
|
|
# Login first to create history
|
|
|
|
|
client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": regular_user.email, "password": "password123"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = client.get(
|
|
|
|
|
"/api/users/me/login-history", headers=auth_headers
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert isinstance(data, list)
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_login_lockout_after_five_attempts(self, client, regular_user):
|
|
|
|
|
"""Test account lockout after 5 failed attempts (default timeout strategy)"""
|
|
|
|
|
# First 4 attempts should return generic invalid error
|
|
|
|
|
for i in range(4):
|
|
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={
|
|
|
|
|
"email": regular_user.email,
|
|
|
|
|
"password": "wrongpassword",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert "Invalid" in data["error"]
|
|
|
|
|
|
|
|
|
|
# 5th attempt should trigger lockout
|
|
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={
|
|
|
|
|
"email": regular_user.email,
|
|
|
|
|
"password": "wrongpassword",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert "locked" in data["error"].lower()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_login_lockout_increments_counter(self, client, regular_user):
|
|
|
|
|
"""Test that failed_login_attempts increments correctly"""
|
|
|
|
|
from app import db
|
|
|
|
|
|
|
|
|
|
# First attempt
|
|
|
|
|
client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={
|
|
|
|
|
"email": regular_user.email,
|
|
|
|
|
"password": "wrongpassword",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.session.refresh(regular_user)
|
|
|
|
|
assert regular_user.failed_login_attempts == 1
|
|
|
|
|
|
|
|
|
|
# Second attempt
|
|
|
|
|
client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={
|
|
|
|
|
"email": regular_user.email,
|
|
|
|
|
"password": "wrongpassword",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.session.refresh(regular_user)
|
|
|
|
|
assert regular_user.failed_login_attempts == 2
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_login_resets_counter_on_success(self, client, regular_user):
|
|
|
|
|
"""Test that successful login resets failed_login_attempts"""
|
|
|
|
|
from app import db
|
|
|
|
|
|
|
|
|
|
# 3 failed attempts
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={
|
|
|
|
|
"email": regular_user.email,
|
|
|
|
|
"password": "wrongpassword",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.session.refresh(regular_user)
|
|
|
|
|
assert regular_user.failed_login_attempts == 3
|
|
|
|
|
|
|
|
|
|
# Successful login resets counter
|
|
|
|
|
response = client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={
|
|
|
|
|
"email": regular_user.email,
|
|
|
|
|
"password": "password123",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
db.session.refresh(regular_user)
|
|
|
|
|
assert regular_user.failed_login_attempts == 0
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_change_password_unauthorized(self, client):
|
|
|
|
|
"""Test password change without authentication"""
|
|
|
|
|
response = client.put(
|
|
|
|
|
"/api/users/me/password",
|
|
|
|
|
json={
|
|
|
|
|
"current_password": "password123",
|
|
|
|
|
"new_password": "newpassword456",
|
|
|
|
|
"confirm_password": "newpassword456",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_get_login_history_unauthorized(self, client):
|
|
|
|
|
"""Test login history without authentication"""
|
|
|
|
|
response = client.get("/api/users/me/login-history")
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_get_login_history_success(self, client, auth_headers):
|
|
|
|
|
"""Test getting login history"""
|
|
|
|
|
response = client.get(
|
|
|
|
|
"/api/users/me/login-history", headers=auth_headers
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.get_json()
|
|
|
|
|
assert isinstance(data, list)
|
|
|
|
|
|
|
|
|
|
@pytest.mark.auth
|
|
|
|
|
def test_login_history_recorded(self, client, regular_user):
|
|
|
|
|
"""Test that login history is recorded"""
|
|
|
|
|
# Log in
|
|
|
|
|
client.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": regular_user.email, "password": "password123"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check history
|
|
|
|
|
from app import db
|
|
|
|
|
from app.models import LoginHistory
|
|
|
|
|
|
|
|
|
|
entries = LoginHistory.query.filter_by(user_id=regular_user.id).all()
|
|
|
|
|
assert len(entries) >= 1
|