mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Post work PR #322
This commit is contained in:
@@ -1112,6 +1112,12 @@ async def run_migrations(conn):
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Add email column to users for Advanced Auth (PR #322)
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR(255)"))
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
"""Integration tests for Advanced Authentication API endpoints.
|
||||
|
||||
Tests the full request/response cycle for SMTP configuration, advanced auth toggle,
|
||||
email-based login, forgot password, admin password reset, and user creation
|
||||
with advanced authentication enabled.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
# Shared SMTP settings data used across test classes
|
||||
SMTP_DATA = {
|
||||
"smtp_host": "smtp.test.com",
|
||||
"smtp_port": 587,
|
||||
"smtp_username": "test@test.com",
|
||||
"smtp_password": "testpass",
|
||||
"smtp_security": "starttls",
|
||||
"smtp_auth_enabled": True,
|
||||
"smtp_from_email": "noreply@test.com",
|
||||
}
|
||||
|
||||
|
||||
async def _setup_admin(async_client: AsyncClient, username: str = "admin", password: str = "adminpass123"):
|
||||
"""Enable auth and create admin user, return admin token."""
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": username,
|
||||
"admin_password": password,
|
||||
},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _setup_smtp_and_advanced_auth(async_client: AsyncClient, token: str):
|
||||
"""Configure SMTP and enable advanced auth. Must mock send_email externally."""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
|
||||
async def _create_regular_user(
|
||||
async_client: AsyncClient, token: str, username: str = "regular", password: str = "regularpass123"
|
||||
):
|
||||
"""Create a regular (non-admin) user and return their token."""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": username, "password": password, "role": "user"},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
class TestSMTPConfigAPI:
|
||||
"""Integration tests for SMTP configuration endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "smtpadmin", "adminpass123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_save_smtp_settings(self, async_client: AsyncClient, admin_token: str):
|
||||
"""POST /auth/smtp with valid settings returns 200."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/smtp",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json=SMTP_DATA,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "saved" in response.json()["message"].lower() or "success" in response.json()["message"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_smtp_settings_masks_password(self, async_client: AsyncClient, admin_token: str):
|
||||
"""GET /auth/smtp returns settings with password masked (None)."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
# Save settings first
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
|
||||
response = await async_client.get("/api/v1/auth/smtp", headers=headers)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["smtp_host"] == "smtp.test.com"
|
||||
assert result["smtp_password"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_smtp_settings_requires_admin(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Non-admin user gets 403 on SMTP endpoints."""
|
||||
user_token = await _create_regular_user(async_client, admin_token, "smtpregular", "pass123456")
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
response = await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = await async_client.get("/api/v1/auth/smtp", headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_save_smtp_settings_no_auth(self, async_client: AsyncClient, admin_token: str):
|
||||
"""No token on SMTP save returns 401."""
|
||||
response = await async_client.post("/api/v1/auth/smtp", json=SMTP_DATA)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_test_smtp_connection(self, async_client: AsyncClient, admin_token: str):
|
||||
"""POST /auth/smtp/test with mocked send_email returns success."""
|
||||
with patch("backend.app.api.routes.auth.send_email"):
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/smtp/test",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
**SMTP_DATA,
|
||||
"test_recipient": "recipient@test.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
|
||||
class TestAdvancedAuthToggleAPI:
|
||||
"""Integration tests for enabling/disabling advanced authentication."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "toggleadmin", "adminpass123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_enable_advanced_auth(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Enable advanced auth after SMTP is configured returns 200."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
# Configure SMTP first
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
|
||||
response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["advanced_auth_enabled"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_enable_advanced_auth_without_smtp(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Enable advanced auth without SMTP configured returns 400."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
assert response.status_code == 400
|
||||
assert "SMTP" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_disable_advanced_auth(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Disable advanced auth returns 200."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
# Enable first
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
response = await async_client.post("/api/v1/auth/advanced-auth/disable", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["advanced_auth_enabled"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_advanced_auth_status_public(self, async_client: AsyncClient, admin_token: str):
|
||||
"""GET /auth/advanced-auth/status is accessible without token."""
|
||||
response = await async_client.get("/api/v1/auth/advanced-auth/status")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "advanced_auth_enabled" in result
|
||||
assert "smtp_configured" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_enable_requires_admin(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Non-admin user gets 403 on enable/disable."""
|
||||
user_token = await _create_regular_user(async_client, admin_token, "toggleregular", "pass123456")
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = await async_client.post("/api/v1/auth/advanced-auth/disable", headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestEmailLoginAPI:
|
||||
"""Integration tests for email-based login."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "emailadmin", "adminpass123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_with_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Login with email address when advanced auth is enabled returns token."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
# Configure SMTP + advanced auth
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
||||
# Create user with email (password auto-generated, so we set one explicitly via update)
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "emailuser", "email": "emailuser@test.com", "role": "user"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
# Set a known password via admin update
|
||||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "knownpassword123"},
|
||||
)
|
||||
|
||||
# Login with email
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "emailuser@test.com", "password": "knownpassword123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_with_email_case_insensitive(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Login with uppercase email matches case-insensitively."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "caseuser", "email": "caseuser@test.com", "role": "user"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "casepassword123"},
|
||||
)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "CASEUSER@TEST.COM", "password": "casepassword123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_with_email_advanced_auth_disabled(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Email login fails when advanced auth is disabled."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
# Create user with email but no advanced auth
|
||||
await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "noemail", "password": "noEmailPass1", "email": "noemail@test.com", "role": "user"},
|
||||
)
|
||||
|
||||
# Try to login with email — should fail since advanced auth is off
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "noemail@test.com", "password": "noEmailPass1"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_with_username_still_works(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Username-based login still works when advanced auth is enabled."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "usernameuser", "email": "usernameuser@test.com", "role": "user"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "usernamepass123"},
|
||||
)
|
||||
|
||||
# Login with username (not email)
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "usernameuser", "password": "usernamepass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
||||
|
||||
class TestForgotPasswordAPI:
|
||||
"""Integration tests for forgot-password flow."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "forgotadmin", "adminpass123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_sends_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""POST /auth/forgot-password with valid email sends reset email."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
||||
# Create a user with email
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "forgotuser", "email": "forgot@test.com", "role": "user"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
|
||||
with patch("backend.app.api.routes.auth.send_email") as mock_send:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "forgot@test.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_send.assert_called_once()
|
||||
# Verify the email was sent to the right address
|
||||
assert mock_send.call_args[0][1] == "forgot@test.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_unknown_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Unknown email still returns 200 (anti-enumeration) but send_email not called."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
with patch("backend.app.api.routes.auth.send_email") as mock_send:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "unknown@test.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_requires_advanced_auth(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Forgot password returns 400 when advanced auth is disabled."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "test@test.com"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "not enabled" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_changes_password(self, async_client: AsyncClient, admin_token: str):
|
||||
"""After forgot-password, old password stops working."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "resetme", "email": "resetme@test.com", "role": "user"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "originalpass123"},
|
||||
)
|
||||
|
||||
# Verify login works with original password
|
||||
login_resp = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "resetme", "password": "originalpass123"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
# Trigger forgot password
|
||||
with patch("backend.app.api.routes.auth.send_email"):
|
||||
await async_client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "resetme@test.com"},
|
||||
)
|
||||
|
||||
# Old password should no longer work
|
||||
login_resp = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "resetme", "password": "originalpass123"},
|
||||
)
|
||||
assert login_resp.status_code == 401
|
||||
|
||||
|
||||
class TestAdminResetPasswordAPI:
|
||||
"""Integration tests for admin password reset endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "resetadmin", "adminpass123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reset_password_sends_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""POST /auth/reset-password sends email to user."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "resetuser", "email": "resetuser@test.com", "role": "user"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
with patch("backend.app.api.routes.auth.send_email") as mock_send:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
headers=headers,
|
||||
json={"user_id": user_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_send.assert_called_once()
|
||||
assert mock_send.call_args[0][1] == "resetuser@test.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reset_password_requires_admin(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Non-admin user gets 403 on reset-password."""
|
||||
# Create regular user before enabling advanced auth (no email required)
|
||||
user_token = await _create_regular_user(async_client, admin_token, "resetregular", "pass123456")
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"user_id": 1},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reset_password_requires_advanced_auth(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Reset password returns 400 when advanced auth is disabled."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
headers=headers,
|
||||
json={"user_id": 999},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "not enabled" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reset_password_user_not_found(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Reset password with invalid user_id returns 404."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
headers=headers,
|
||||
json={"user_id": 99999},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reset_password_user_no_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Reset password for user without email returns 400."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
# Save SMTP and enable advanced auth
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
# Disable advanced auth temporarily to create a user without email
|
||||
await async_client.post("/api/v1/auth/advanced-auth/disable", headers=headers)
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "noemailuser", "password": "noemail123456", "role": "user"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
# Re-enable advanced auth
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
headers=headers,
|
||||
json={"user_id": user_id},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "email" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestUserCreationAdvancedAuth:
|
||||
"""Integration tests for user creation with advanced auth enabled."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "createadmin", "adminpass123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_advanced_auth_requires_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Creating user without email when advanced auth is on returns 400."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "noemailcreate", "role": "user"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "email" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_advanced_auth_auto_password(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Creating user with email auto-generates password and sends welcome email."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email") as mock_send:
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "autopassuser", "email": "autopass@test.com", "role": "user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
result = response.json()
|
||||
assert result["username"] == "autopassuser"
|
||||
assert result["email"] == "autopass@test.com"
|
||||
# Welcome email should have been sent
|
||||
mock_send.assert_called_once()
|
||||
assert mock_send.call_args[0][1] == "autopass@test.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_duplicate_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Creating two users with the same email returns 400."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
resp1 = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "dupemail1", "email": "dupe@test.com", "role": "user"},
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
|
||||
resp2 = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "dupemail2", "email": "dupe@test.com", "role": "user"},
|
||||
)
|
||||
|
||||
assert resp2.status_code == 400
|
||||
assert "email" in resp2.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_response_includes_email(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Created user response includes email field."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "emailresp", "email": "emailresp@test.com", "role": "user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
result = response.json()
|
||||
assert "email" in result
|
||||
assert result["email"] == "emailresp@test.com"
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Unit tests for email service.
|
||||
|
||||
These tests verify email template rendering and HTML formatting.
|
||||
These tests verify email template rendering, HTML formatting,
|
||||
password generation, and SMTP settings persistence.
|
||||
"""
|
||||
|
||||
import string
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -12,6 +14,8 @@ from backend.app.models.notification_template import NotificationTemplate
|
||||
from backend.app.services.email_service import (
|
||||
create_password_reset_email_from_template,
|
||||
create_welcome_email_from_template,
|
||||
generate_secure_password,
|
||||
render_template,
|
||||
)
|
||||
|
||||
|
||||
@@ -161,3 +165,81 @@ class TestEmailTemplateFormatting:
|
||||
assert "<script>" in html_body
|
||||
# Verify no unescaped script tags
|
||||
assert "<script>" not in html_body
|
||||
|
||||
|
||||
class TestGenerateSecurePassword:
|
||||
"""Tests for generate_secure_password()."""
|
||||
|
||||
def test_password_default_length(self):
|
||||
"""Default password is 16 characters."""
|
||||
password = generate_secure_password()
|
||||
assert len(password) == 16
|
||||
|
||||
def test_password_custom_length(self):
|
||||
"""Custom length is respected."""
|
||||
password = generate_secure_password(24)
|
||||
assert len(password) == 24
|
||||
|
||||
def test_password_has_required_char_types(self):
|
||||
"""Password contains uppercase, lowercase, digit, and special character."""
|
||||
# Run multiple times to reduce flakiness from random shuffling
|
||||
for _ in range(5):
|
||||
password = generate_secure_password()
|
||||
assert any(c in string.ascii_uppercase for c in password), "Missing uppercase"
|
||||
assert any(c in string.ascii_lowercase for c in password), "Missing lowercase"
|
||||
assert any(c in string.digits for c in password), "Missing digit"
|
||||
assert any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password), "Missing special"
|
||||
|
||||
|
||||
class TestRenderTemplate:
|
||||
"""Tests for render_template()."""
|
||||
|
||||
def test_render_template_basic(self):
|
||||
"""Placeholders are replaced correctly."""
|
||||
result = render_template("Hello {name}, welcome to {app}!", {"name": "Alice", "app": "BamBuddy"})
|
||||
assert result == "Hello Alice, welcome to BamBuddy!"
|
||||
|
||||
def test_render_template_removes_unreplaced(self):
|
||||
"""Unreplaced placeholders are removed."""
|
||||
result = render_template("Hello {name}, your code is {code}", {"name": "Bob"})
|
||||
assert result == "Hello Bob, your code is "
|
||||
|
||||
|
||||
class TestSMTPSettingsPersistence:
|
||||
"""Tests for save_smtp_settings() and get_smtp_settings() round-trip."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_and_retrieve_smtp_settings(self, db_session):
|
||||
"""Save SMTP settings, then retrieve them and verify values match."""
|
||||
from backend.app.schemas.auth import SMTPSettings
|
||||
from backend.app.services.email_service import get_smtp_settings, save_smtp_settings
|
||||
|
||||
settings = SMTPSettings(
|
||||
smtp_host="mail.example.com",
|
||||
smtp_port=465,
|
||||
smtp_username="user@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_security="ssl",
|
||||
smtp_auth_enabled=True,
|
||||
smtp_from_email="noreply@example.com",
|
||||
)
|
||||
await save_smtp_settings(db_session, settings)
|
||||
await db_session.commit()
|
||||
|
||||
retrieved = await get_smtp_settings(db_session)
|
||||
assert retrieved is not None
|
||||
assert retrieved.smtp_host == "mail.example.com"
|
||||
assert retrieved.smtp_port == 465
|
||||
assert retrieved.smtp_username == "user@example.com"
|
||||
assert retrieved.smtp_password == "secret"
|
||||
assert retrieved.smtp_security == "ssl"
|
||||
assert retrieved.smtp_auth_enabled is True
|
||||
assert retrieved.smtp_from_email == "noreply@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_smtp_settings_returns_none_when_unconfigured(self, db_session):
|
||||
"""Empty DB returns None for SMTP settings."""
|
||||
from backend.app.services.email_service import get_smtp_settings
|
||||
|
||||
result = await get_smtp_settings(db_session)
|
||||
assert result is None
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { KProfile } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
|
||||
@@ -13,7 +13,7 @@ export function EmailSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
const [smtpSettings, setSMTPSettings] = useState<SMTPSettings>({
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
@@ -143,6 +143,75 @@ export function EmailSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Advanced Authentication Toggle - Only show when SMTP is configured */}
|
||||
{advancedAuthStatus?.smtp_configured && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t('settings.email.advancedAuth') || 'Advanced Authentication'}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleToggleAdvancedAuth}
|
||||
disabled={toggleAdvancedAuthMutation.isPending}
|
||||
variant={advancedAuthStatus?.advanced_auth_enabled ? 'danger' : 'primary'}
|
||||
>
|
||||
{advancedAuthStatus?.advanced_auth_enabled ? (
|
||||
<>
|
||||
<Unlock className="w-4 h-4" />
|
||||
{t('settings.email.disable') || 'Disable'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4" />
|
||||
{t('settings.email.enable') || 'Enable'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{advancedAuthStatus?.advanced_auth_enabled ? (
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-white font-medium">
|
||||
{t('settings.email.advancedAuthEnabled') || 'Advanced Authentication is enabled'}
|
||||
</p>
|
||||
<ul className="text-sm text-green-300 space-y-1 list-disc list-inside">
|
||||
<li>{t('settings.email.feature1') || 'Passwords are auto-generated and emailed to new users'}</li>
|
||||
<li>{t('settings.email.feature2') || 'Users can login with username or email'}</li>
|
||||
<li>{t('settings.email.feature3') || 'Forgot password feature is available'}</li>
|
||||
<li>{t('settings.email.feature4') || 'Admins can reset user passwords via email'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-white font-medium">
|
||||
{t('settings.email.advancedAuthDisabled') || 'Advanced Authentication is disabled'}
|
||||
</p>
|
||||
<p className="text-sm text-yellow-300">
|
||||
{t('settings.email.advancedAuthDisabledDesc') || 'Enable advanced authentication to activate email-based features for user management.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* SMTP Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -328,74 +397,6 @@ export function EmailSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Advanced Authentication Toggle - Only show when SMTP is configured */}
|
||||
{advancedAuthStatus?.smtp_configured && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t('settings.email.advancedAuth') || 'Advanced Authentication'}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleToggleAdvancedAuth}
|
||||
disabled={toggleAdvancedAuthMutation.isPending}
|
||||
variant={advancedAuthStatus?.advanced_auth_enabled ? 'danger' : 'primary'}
|
||||
>
|
||||
{advancedAuthStatus?.advanced_auth_enabled ? (
|
||||
<>
|
||||
<Unlock className="w-4 h-4" />
|
||||
{t('settings.email.disable') || 'Disable'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4" />
|
||||
{t('settings.email.enable') || 'Enable'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{advancedAuthStatus?.advanced_auth_enabled ? (
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-white font-medium">
|
||||
{t('settings.email.advancedAuthEnabled') || 'Advanced Authentication is enabled'}
|
||||
</p>
|
||||
<ul className="text-sm text-green-300 space-y-1 list-disc list-inside">
|
||||
<li>{t('settings.email.feature1') || 'Passwords are auto-generated and emailed to new users'}</li>
|
||||
<li>{t('settings.email.feature2') || 'Users can login with username or email'}</li>
|
||||
<li>{t('settings.email.feature3') || 'Forgot password feature is available'}</li>
|
||||
<li>{t('settings.email.feature4') || 'Admins can reset user passwords via email'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-white font-medium">
|
||||
{t('settings.email.advancedAuthDisabled') || 'Advanced Authentication is disabled'}
|
||||
</p>
|
||||
<p className="text-sm text-yellow-300">
|
||||
{t('settings.email.advancedAuthDisabledDesc') || 'Enable advanced authentication to activate email-based features for user management.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,18 +446,18 @@ export function SettingsPage() {
|
||||
const handleCreateUser = () => {
|
||||
// Use the status from the query hook
|
||||
const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
|
||||
|
||||
|
||||
if (!userFormData.username) {
|
||||
showToast(t('settings.toast.fillRequiredFields'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Email is required when advanced auth is enabled
|
||||
if (advancedAuthEnabled && !userFormData.email) {
|
||||
showToast('Email is required when advanced authentication is enabled', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Password validation only when advanced auth is disabled
|
||||
if (!advancedAuthEnabled) {
|
||||
if (!userFormData.password) {
|
||||
@@ -473,7 +473,7 @@ export function SettingsPage() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createUserMutation.mutate({
|
||||
username: userFormData.username,
|
||||
password: advancedAuthEnabled ? undefined : userFormData.password,
|
||||
@@ -996,6 +996,9 @@ export function SettingsPage() {
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{t('settings.tabs.globalEmail') || 'Global Email'}
|
||||
{advancedAuthStatus?.advanced_auth_enabled && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('notifications')}
|
||||
@@ -4097,8 +4100,8 @@ export function SettingsPage() {
|
||||
<Button
|
||||
onClick={() => handleUpdateUser(editingUserId)}
|
||||
disabled={
|
||||
updateUserMutation.isPending ||
|
||||
!userFormData.username ||
|
||||
updateUserMutation.isPending ||
|
||||
!userFormData.username ||
|
||||
(advancedAuthStatus?.advanced_auth_enabled && !userFormData.email) ||
|
||||
Boolean(!advancedAuthStatus?.advanced_auth_enabled && userFormData.password && (userFormData.password !== userFormData.confirmPassword || userFormData.password.length < 6))
|
||||
}
|
||||
@@ -4396,7 +4399,9 @@ export function SettingsPage() {
|
||||
|
||||
{/* Email Tab */}
|
||||
{activeTab === 'email' && (
|
||||
<EmailSettings />
|
||||
<div className="max-w-2xl">
|
||||
<EmailSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup Tab */}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BnphTAH8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B1hpQ91Q.css">
|
||||
<script type="module" crossorigin src="/assets/index-GkrFU7v8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DLgJjh2G.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user