diff --git a/app/api/billing.py b/app/api/billing.py index 9c5582b0..85528608 100644 --- a/app/api/billing.py +++ b/app/api/billing.py @@ -260,7 +260,7 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dic @require_login async def billing_success(request: Request) -> Any: """Show a success page after a completed Stripe Checkout.""" - return _templates.TemplateResponse("billing_success.html", {"request": request}) + return _templates.TemplateResponse(request, "billing_success.html") # --------------------------------------------------------------------------- diff --git a/app/api/dropbox.py b/app/api/dropbox.py index fe1b13e9..a7c4d7f0 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -10,7 +10,7 @@ import httpx from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from sqlalchemy.orm import Session -from app.auth import require_login +from app.auth import AUTH_ENABLED, require_login from app.config import settings from app.database import get_db from app.utils.oauth_helper import exchange_oauth_token @@ -24,7 +24,13 @@ router = APIRouter() def _require_admin(request: Request) -> dict: - """Dependency to ensure the current user is an admin.""" + """Dependency to ensure the current user is an admin. + + When AUTH_ENABLED=False (single-user/development mode), admin checks are + skipped because there is no authentication at all. + """ + if not AUTH_ENABLED: + return {} user = request.session.get("user") if not user or not user.get("is_admin"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") diff --git a/app/api/local_auth.py b/app/api/local_auth.py index 2b68003f..e946f484 100644 --- a/app/api/local_auth.py +++ b/app/api/local_auth.py @@ -101,9 +101,9 @@ async def signup_page(request: Request) -> Any: if not settings.allow_local_signup: return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302) return templates.TemplateResponse( + request, "signup.html", - { - "request": request, + context={ "csrf_token": getattr(request.state, "csrf_token", ""), "app_version": settings.version, }, @@ -113,16 +113,16 @@ async def signup_page(request: Request) -> Any: @router.get("/verify-email-sent", include_in_schema=False) async def verify_email_sent_page(request: Request) -> Any: """Render the verify-email-sent confirmation page.""" - return templates.TemplateResponse("verify_email_sent.html", {"request": request}) + return templates.TemplateResponse(request, "verify_email_sent.html") @router.get("/forgot-username", include_in_schema=False) async def forgot_username_page(request: Request) -> Any: """Render the forgot-username page where users can request a username reminder email.""" return templates.TemplateResponse( + request, "forgot_username.html", - { - "request": request, + context={ "csrf_token": getattr(request.state, "csrf_token", ""), "app_version": settings.version, }, @@ -133,9 +133,9 @@ async def forgot_username_page(request: Request) -> Any: async def forgot_password_page(request: Request) -> Any: """Render the forgot-password page where users can request a reset email.""" return templates.TemplateResponse( + request, "forgot_password.html", - { - "request": request, + context={ "csrf_token": getattr(request.state, "csrf_token", ""), "app_version": settings.version, }, @@ -147,9 +147,9 @@ async def reset_password_page(request: Request) -> Any: """Render the password reset form page.""" token = request.query_params.get("token", "") return templates.TemplateResponse( + request, "password_reset_form.html", - { - "request": request, + context={ "token": token, "csrf_token": getattr(request.state, "csrf_token", ""), "app_version": settings.version, diff --git a/app/api/onedrive.py b/app/api/onedrive.py index a89103d3..a61acd1f 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -11,9 +11,10 @@ import httpx from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from sqlalchemy.orm import Session -from app.auth import require_login +from app.auth import AUTH_ENABLED, require_login from app.config import settings from app.database import get_db +from app.utils.env_utils import update_env_file as _update_env_file_auto from app.utils.oauth_helper import exchange_oauth_token from app.utils.settings_service import save_setting_to_db, update_env_file from app.utils.settings_sync import notify_settings_updated @@ -25,7 +26,13 @@ router = APIRouter() def _require_admin(request: Request) -> dict: - """Dependency to ensure the current user is an admin.""" + """Dependency to ensure the current user is an admin. + + When AUTH_ENABLED=False (single-user/development mode), admin checks are + skipped because there is no authentication at all. + """ + if not AUTH_ENABLED: + return {} user = request.session.get("user") if not user or not user.get("is_admin"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") @@ -127,7 +134,7 @@ async def test_onedrive_token(request: Request): settings.onedrive_refresh_token = new_refresh_token # Also try to update .env file if it exists - update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token}) + _update_env_file_auto({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token}) # Persist the rotated refresh token to the database try: diff --git a/app/auth.py b/app/auth.py index c5883dac..17621e3d 100644 --- a/app/auth.py +++ b/app/auth.py @@ -332,9 +332,9 @@ async def login(request: Request): ) return templates.TemplateResponse( + request, "login.html", - { - "request": request, + context={ "error": request.query_params.get("error"), "message": request.query_params.get("message"), "show_oauth": OAUTH_CONFIGURED, diff --git a/app/main.py b/app/main.py index 21b50f30..afa66525 100644 --- a/app/main.py +++ b/app/main.py @@ -282,10 +282,16 @@ async def lifespan(app: FastAPI): yield # Shutdown: Cleanup tasks - logging.info("Application shutting down") + try: + logging.info("Application shutting down") + except Exception: + pass # During test teardown, logging streams may already be closed # Send shutdown notification - notify_shutdown() + try: + notify_shutdown() + except Exception: + pass # During test teardown, I/O streams may already be closed app = FastAPI( @@ -412,15 +418,13 @@ async def http_exception_handler(request: Request, exc: HTTPException): # For frontend routes, return appropriate HTML templates # Handle 404 errors with a custom template if exc.status_code == 404: - return _error_templates.TemplateResponse( - "404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND - ) + return _error_templates.TemplateResponse(request, "404.html", status_code=status.HTTP_404_NOT_FOUND) # For other HTTP errors, we could create specific templates or use a generic one # For now, return a simple error page return _error_templates.TemplateResponse( + request, "404.html", # Reuse 404 template for other errors, or create a generic error template - {"request": request}, status_code=exc.status_code, ) @@ -440,8 +444,9 @@ async def custom_500_handler(request: Request, exc: Exception): # Serve the 500 template for non-API routes return _error_templates.TemplateResponse( + request, "500.html", - {"request": request, "exc": exc}, + context={"exc": exc}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) diff --git a/app/views/share.py b/app/views/share.py index 118e0ee9..925342f6 100644 --- a/app/views/share.py +++ b/app/views/share.py @@ -23,6 +23,7 @@ templates = Jinja2Templates(directory=str(_templates_dir)) async def shared_link_view(request: Request, token: str): """Render the public share landing page for a given token.""" return templates.TemplateResponse( + request, "shared_link_view.html", - {"request": request, "token": token}, + context={"token": token}, ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 85e047e8..d3af75ca 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -430,8 +430,8 @@ class TestLoginFunction: # Verify TemplateResponse was called with correct context mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args - assert call_args[0][0] == "login.html" - context = call_args[0][1] + assert call_args[0][1] == "login.html" + context = call_args.kwargs["context"] assert context["error"] == "Test error" assert context["message"] == "Test message" @@ -450,7 +450,7 @@ class TestLoginFunction: mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args - context = call_args[0][1] + context = call_args.kwargs["context"] assert context["error"] is None assert context["message"] is None diff --git a/tests/test_auth_module.py b/tests/test_auth_module.py index 233dfd5e..ba41b756 100644 --- a/tests/test_auth_module.py +++ b/tests/test_auth_module.py @@ -281,7 +281,7 @@ class TestLoginEndpoint: # Verify template was rendered with OAuth enabled mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args - context = call_args[0][1] + context = call_args.kwargs["context"] assert context["show_oauth"] is True assert context["oauth_provider_name"] == "Test SSO" diff --git a/tests/test_coverage_remaining_gaps.py b/tests/test_coverage_remaining_gaps.py index b4bf96ec..14230d60 100644 --- a/tests/test_coverage_remaining_gaps.py +++ b/tests/test_coverage_remaining_gaps.py @@ -69,8 +69,8 @@ class TestViewsBase: context = {"request": req} template_response_with_version("template.html", context) - args, _ = mock_orig.call_args - assert args[1].get("csrf_token") == "my-csrf" + _, kwargs = mock_orig.call_args + assert kwargs["context"].get("csrf_token") == "my-csrf" def test_kwargs_context_no_request(self): """Test kwargs context path when request is not in context.""" diff --git a/tests/test_dark_mode.py b/tests/test_dark_mode.py index 82514a20..39a2a01c 100644 --- a/tests/test_dark_mode.py +++ b/tests/test_dark_mode.py @@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection: captured = {} - def fake_original(name, ctx, **kw): - captured.update(ctx) + def fake_original(request, name, **kw): + captured.update(kw.get("context", {})) with patch("app.views.base.original_template_response", side_effect=fake_original): mock_request = MagicMock() @@ -73,8 +73,8 @@ class TestDarkModeTemplateInjection: captured = {} - def fake_original(name, ctx, **kw): - captured.update(ctx) + def fake_original(request, name, **kw): + captured.update(kw.get("context", {})) with patch("app.views.base.original_template_response", side_effect=fake_original): mock_request = MagicMock() diff --git a/tests/test_imap_tasks.py b/tests/test_imap_tasks.py index 0a946739..36a73ed5 100644 --- a/tests/test_imap_tasks.py +++ b/tests/test_imap_tasks.py @@ -499,6 +499,7 @@ class TestPullAllInboxes: class TestPullInbox: """Tests for pull_inbox function.""" + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") @patch("app.tasks.imap_tasks.save_processed_emails") @@ -528,6 +529,7 @@ class TestPullInbox: mock_mail.close.assert_called_once() mock_mail.logout.assert_called_once() + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.imaplib.IMAP4") @patch("app.tasks.imap_tasks.load_processed_emails") def test_non_ssl_connection(self, mock_load, mock_imap_class): @@ -606,6 +608,7 @@ class TestPullInbox: # Should select INBOX as fallback assert any(call_args[0][0] == "INBOX" for call_args in mock_mail.select.call_args_list) + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") def test_search_failure_handling(self, mock_load, mock_imap_class): @@ -632,6 +635,7 @@ class TestPullInbox: mock_mail.close.assert_called_once() mock_mail.logout.assert_called_once() + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") @@ -674,6 +678,7 @@ class TestPullInbox: mock_mail.store.assert_called_with(b"1", "-FLAGS", "\\Seen") mock_save.assert_called() + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") @@ -917,6 +922,7 @@ class TestPullInbox: # Should not process the message mock_mail.store.assert_not_called() + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") def test_handles_fetch_failure(self, mock_load, mock_imap_class): @@ -1019,6 +1025,7 @@ class TestPullInbox: # Processed emails cache should still be updated mock_save.assert_called() + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") @@ -1378,6 +1385,7 @@ class TestAcquireReleaseLockEdgeCases: class TestPullInboxEdgeCases: """Test edge cases for pull_inbox function.""" + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.load_processed_emails") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.settings") @@ -1428,6 +1436,7 @@ class TestPullInboxEdgeCases: # Should skip processing since no Message-ID mock_fetch.assert_not_called() + @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.load_processed_emails") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.settings") diff --git a/tests/test_social_login.py b/tests/test_social_login.py index 54c81931..bb42d887 100644 --- a/tests/test_social_login.py +++ b/tests/test_social_login.py @@ -345,7 +345,7 @@ class TestLoginPageSocialProviders: mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args - context = call_args[0][1] + context = call_args.kwargs["context"] assert context["social_providers"] == mock_providers @pytest.mark.asyncio @@ -371,7 +371,7 @@ class TestLoginPageSocialProviders: mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args - context = call_args[0][1] + context = call_args.kwargs["context"] assert context["social_providers"] == {}