Post work PR #322

This commit is contained in:
maziggy
2026-02-10 17:08:34 +01:00
parent 6ecc29de54
commit ea5b9b3011
12 changed files with 1232 additions and 9180 deletions
+6
View File
@@ -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';
+70 -69
View File
@@ -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>
);
}
+12 -7
View File
@@ -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
View File
@@ -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>