Browse Source

Post work PR #322

maziggy 3 months ago
parent
commit
ea5b9b3011

+ 6 - 0
backend/app/core/database.py

@@ -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."""

+ 625 - 0
backend/tests/integration/test_advanced_auth_api.py

@@ -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"

+ 83 - 1
backend/tests/unit/services/test_email_service.py

@@ -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

+ 0 - 1
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -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
frontend/src/components/EmailSettings.tsx

@@ -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
frontend/src/pages/SettingsPage.tsx

@@ -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 it is too large
+ 0 - 0
static/assets/index-B1hpQ91Q.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BwfbnBQ9.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DLgJjh2G.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-GkrFU7v8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-QQNcmTSY.js


+ 2 - 2
static/index.html

@@ -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>

Some files were not shown because too many files changed in this diff