feat: authentication

This commit is contained in:
Leon
2025-07-19 10:12:11 +02:00
parent 95170e7201
commit 6f7503039d
57 changed files with 1405 additions and 244 deletions

View File

@@ -1,6 +1,7 @@
import os
os.environ["LETTERFEED_DATABASE_URL"] = "sqlite:///./test.db"
os.environ["LETTERFEED_SECRET_KEY"] = "testsecret"
import pytest
from fastapi.testclient import TestClient

View File

@@ -0,0 +1,170 @@
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.crud.settings import create_or_update_settings
from app.schemas.settings import SettingsCreate
def test_auth_status_disabled(client: TestClient):
"""Test auth status when auth is disabled."""
response = client.get("/auth/status")
assert response.status_code == 200
assert response.json() == {"auth_enabled": False}
def test_auth_status_enabled(client: TestClient, db_session: Session):
"""Test auth status when auth is enabled."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
response = client.get("/auth/status")
assert response.status_code == 200
assert response.json() == {"auth_enabled": True}
def test_login_endpoint(client: TestClient, db_session: Session):
"""Test the /auth/login endpoint directly."""
# Setup auth credentials in the database
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
# Test with correct credentials
login_data = {"username": "admin", "password": "password"}
response = client.post("/auth/login", data=login_data)
assert response.status_code == 200
json_response = response.json()
assert "access_token" in json_response
assert json_response["token_type"] == "bearer"
# Test with incorrect password
login_data["password"] = "wrongpassword"
response = client.post("/auth/login", data=login_data)
assert response.status_code == 401
# Test with incorrect username
login_data["username"] = "wronguser"
login_data["password"] = "password"
response = client.post("/auth/login", data=login_data)
assert response.status_code == 401
# Test with no credentials
response = client.post("/auth/login")
assert response.status_code == 422 # FastAPI validation error for missing form data
def test_protected_route_no_auth(client: TestClient, db_session: Session):
"""Test accessing a protected route without auth enabled."""
# Health is not protected, newsletters is.
response = client.get("/newsletters")
assert response.status_code == 200
def test_protected_route_with_auth_fail(client: TestClient, db_session: Session):
"""Test accessing a protected route with auth enabled but wrong credentials."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
response = client.get("/newsletters")
assert response.status_code == 401
response = client.get(
"/newsletters", headers={"Authorization": "Bearer wrongtoken"}
)
assert response.status_code == 401
def test_protected_route_with_auth_success(client: TestClient, db_session: Session):
"""Test accessing a protected route with auth enabled and correct credentials."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
# First, log in to get a token
login_data = {"username": "admin", "password": "password"}
response = client.post("/auth/login", data=login_data)
token = response.json()["access_token"]
# Then, use the token to access the protected route
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/newsletters", headers=headers)
assert response.status_code == 200
def test_unprotected_route_with_auth(client: TestClient, db_session: Session):
"""Test that feed endpoint is not protected."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
# Log in to get a token
login_data = {"username": "admin", "password": "password"}
login_response = client.post("/auth/login", data=login_data)
token = login_response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Create a newsletter to get a feed from
newsletter_data = {"name": "Test Newsletter", "sender_emails": ["test@test.com"]}
create_response = client.post("/newsletters", json=newsletter_data, headers=headers)
newsletter_id = create_response.json()["id"]
response = client.get(f"/feeds/{newsletter_id}")
assert response.status_code == 200
def test_auth_with_env_vars(client: TestClient):
"""Test authentication using environment variables."""
with patch("app.core.auth.env_settings") as mock_env_settings:
mock_env_settings.auth_username = "env_admin"
mock_env_settings.auth_password = "env_password"
mock_env_settings.secret_key = "test-secret"
mock_env_settings.algorithm = "HS256"
mock_env_settings.access_token_expire_minutes = 30
# Log in to get a token
login_data = {"username": "env_admin", "password": "env_password"}
login_response = client.post("/auth/login", data=login_data)
assert login_response.status_code == 200
token = login_response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/newsletters", headers=headers)
assert response.status_code == 200
response = client.get(
"/newsletters", headers={"Authorization": "Bearer wrongtoken"}
)
assert response.status_code == 401
response = client.get("/auth/status")
assert response.status_code == 200
assert response.json() == {"auth_enabled": True}

View File

@@ -167,8 +167,12 @@ def test_process_emails_auto_add_sender(mock_imap, db_session: Session):
@patch("app.services.email_processor.imaplib.IMAP4_SSL")
def test_process_emails_no_settings(mock_imap, db_session: Session):
"""Test processing emails with no settings in the database."""
# No settings in the DB
"""Test processing emails with no settings configured."""
# This test ensures that email processing is skipped if settings are not configured.
# In the new flow, initial settings are created at startup, so we call it here.
from app.crud.settings import create_initial_settings
create_initial_settings(db_session)
process_emails(db_session)
mock_imap.assert_not_called()

View File

@@ -20,10 +20,19 @@ def test_create_or_update_settings(db_session: Session):
search_folder="INBOX",
move_to_folder="Archive",
mark_as_read=True,
auth_username="user",
auth_password="password",
)
settings = create_or_update_settings(db_session, settings_data)
assert settings.imap_server == "imap.test.com"
assert settings.mark_as_read
assert settings.auth_username == "user"
# check password hash
from app.models.settings import Settings as SettingsModel
db_settings = db_session.query(SettingsModel).first()
assert db_settings.auth_password_hash is not None
updated_settings_data = SettingsCreate(
imap_server="imap.updated.com",
@@ -32,11 +41,13 @@ def test_create_or_update_settings(db_session: Session):
search_folder="Inbox",
move_to_folder=None,
mark_as_read=False,
auth_username="new_user",
)
updated_settings = create_or_update_settings(db_session, updated_settings_data)
assert updated_settings.imap_server == "imap.updated.com"
assert not updated_settings.mark_as_read
assert updated_settings.move_to_folder is None
assert updated_settings.auth_username == "new_user"
def test_get_settings(db_session: Session):
@@ -62,6 +73,8 @@ def test_get_settings_with_env_override(db_session: Session):
imap_username="db_user",
imap_password="db_pass",
email_check_interval=15,
auth_username="db_user",
auth_password="db_password",
)
create_or_update_settings(db_session, db_settings_data)
@@ -72,8 +85,11 @@ def test_get_settings_with_env_override(db_session: Session):
"imap_username": "env_user",
"imap_password": "env_pass",
"email_check_interval": 30,
"auth_username": "env_auth_user",
"auth_password": "env_auth_password",
}
mock_env_settings.imap_password = "env_pass"
mock_env_settings.auth_password = "env_auth_password"
# 3. Call get_settings and assert the override
settings = get_settings(db_session, with_password=True)
@@ -81,8 +97,10 @@ def test_get_settings_with_env_override(db_session: Session):
assert settings.imap_username == "env_user"
assert settings.imap_password == "env_pass"
assert settings.email_check_interval == 30
assert settings.auth_username == "env_auth_user"
assert "imap_server" in settings.locked_fields
assert "imap_username" in settings.locked_fields
assert "auth_username" in settings.locked_fields
# 4. Call create_or_update_settings and assert that locked fields are not updated
update_data = SettingsCreate(
@@ -90,11 +108,14 @@ def test_get_settings_with_env_override(db_session: Session):
imap_username="new_user",
imap_password="new_pass",
email_check_interval=45,
auth_username="new_auth_user",
auth_password="new_auth_password",
)
updated_settings = create_or_update_settings(db_session, update_data)
assert updated_settings.imap_server == "env.imap.com" # Should not change
assert updated_settings.imap_username == "env_user" # Should not change
assert updated_settings.email_check_interval == 30 # Should not change
assert updated_settings.auth_username == "env_auth_user" # Should not change
def test_create_newsletter(db_session: Session):

View File

@@ -2,6 +2,10 @@ import uuid
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.crud.settings import create_or_update_settings
from app.schemas.settings import SettingsCreate
def test_health_check(client: TestClient):
@@ -11,12 +15,8 @@ def test_health_check(client: TestClient):
assert response.json() == {"status": "ok"}
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_update_imap_settings(mock_imap, client: TestClient):
def test_update_imap_settings(client: TestClient):
"""Test updating IMAP settings."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
@@ -34,12 +34,8 @@ def test_update_imap_settings(mock_imap, client: TestClient):
assert response.json()["mark_as_read"]
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_get_imap_settings(mock_imap, client: TestClient):
def test_get_imap_settings(client: TestClient):
"""Test getting IMAP settings."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
@@ -57,20 +53,20 @@ def test_get_imap_settings(mock_imap, client: TestClient):
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_test_imap_connection(mock_imap, client: TestClient):
def test_test_imap_connection(mock_imap, client: TestClient, db_session: Session):
"""Test the IMAP connection."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
"imap_password": "password",
"search_folder": "INBOX",
"move_to_folder": "Processed",
"mark_as_read": True,
}
client.post("/imap/settings", json=settings_data)
settings_data = SettingsCreate(
imap_server="imap.example.com",
imap_username="test@example.com",
imap_password="password",
search_folder="INBOX",
move_to_folder="Processed",
mark_as_read=True,
)
create_or_update_settings(db_session, settings_data)
response = client.post("/imap/test")
assert response.status_code == 200
@@ -78,7 +74,7 @@ def test_test_imap_connection(mock_imap, client: TestClient):
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_get_imap_folders(mock_imap, client: TestClient):
def test_get_imap_folders(mock_imap, client: TestClient, db_session: Session):
"""Test getting IMAP folders."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
@@ -87,15 +83,15 @@ def test_get_imap_folders(mock_imap, client: TestClient):
[b'(NOCONNECT NOSELECT) "/" "INBOX"', b'(NOCONNECT NOSELECT) "/" "Processed"'],
)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
"imap_password": "password",
"search_folder": "INBOX",
"move_to_folder": "Processed",
"mark_as_read": True,
}
client.post("/imap/settings", json=settings_data)
settings_data = SettingsCreate(
imap_server="imap.example.com",
imap_username="test@example.com",
imap_password="password",
search_folder="INBOX",
move_to_folder="Processed",
mark_as_read=True,
)
create_or_update_settings(db_session, settings_data)
response = client.get("/imap/folders")
assert response.status_code == 200
@@ -141,7 +137,7 @@ def test_get_single_newsletter(client: TestClient):
"""Test getting a single newsletter."""
unique_email = f"newsletter_{uuid.uuid4()}@example.com"
newsletter_data = {"name": "Third Newsletter", "sender_emails": [unique_email]}
create_response = client.post("/newsletters/", json=newsletter_data)
create_response = client.post("/newsletters", json=newsletter_data)
newsletter_id = create_response.json()["id"]
response = client.get(f"/newsletters/{newsletter_id}")
@@ -151,7 +147,7 @@ def test_get_single_newsletter(client: TestClient):
def test_get_nonexistent_newsletter(client: TestClient):
"""Test getting a nonexistent newsletter."""
response = client.get("/newsletters/999")
response = client.get("/newsletters/nonexistent")
assert response.status_code == 404
assert response.json() == {"detail": "Newsletter not found"}
@@ -160,7 +156,7 @@ def test_get_newsletter_feed(client: TestClient):
"""Test generating a newsletter feed."""
unique_email = f"feed_test_{uuid.uuid4()}@example.com"
newsletter_data = {"name": "Feed Test Newsletter", "sender_emails": [unique_email]}
create_response = client.post("/newsletters/", json=newsletter_data)
create_response = client.post("/newsletters", json=newsletter_data)
newsletter_id = create_response.json()["id"]
# Add some entries to the newsletter
@@ -195,6 +191,6 @@ def test_get_newsletter_feed(client: TestClient):
def test_get_newsletter_feed_nonexistent_newsletter(client: TestClient):
"""Test generating a feed for a nonexistent newsletter."""
response = client.get("/feeds/999")
response = client.get("/feeds/nonexistent")
assert response.status_code == 404
assert response.json() == {"detail": "Newsletter not found"}