diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index 4b659717..b83bbc71 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -31,12 +31,41 @@ def setup_exception_handlers(app): async def custom_exception_handler(request: Request, exc: WizamartException): """Handle custom exceptions with context-aware rendering.""" - # Special handling for 401 on HTML page requests (redirect to login) - if exc.status_code == 401 and _is_html_page_request(request): + # Special handling for auth errors on HTML page requests (redirect to login) + # This includes both: + # - 401 errors: Not authenticated (expired/invalid token) + # - 403 errors with specific auth codes: Authenticated but wrong context + # (e.g., vendor token on admin page, role mismatch) + # These codes indicate the user should re-authenticate with correct credentials + auth_redirect_error_codes = { + # Auth-level errors + "ADMIN_REQUIRED", + "INSUFFICIENT_PERMISSIONS", + "USER_NOT_ACTIVE", + # Vendor-level auth errors + "VENDOR_ACCESS_DENIED", + "UNAUTHORIZED_VENDOR_ACCESS", + "VENDOR_OWNER_ONLY", + "INSUFFICIENT_VENDOR_PERMISSIONS", + # Customer-level auth errors + "CUSTOMER_NOT_AUTHORIZED", + } + + should_redirect = ( + _is_html_page_request(request) + and ( + exc.status_code == 401 + or (exc.status_code == 403 and exc.error_code in auth_redirect_error_codes) + ) + ) + + if should_redirect: logger.info( - f"401 on HTML page request - redirecting to login: {request.url.path}", + f"Auth error on HTML page request - redirecting to login: {request.url.path}", extra={ "path": request.url.path, + "status_code": exc.status_code, + "error_code": exc.error_code, "accept": request.headers.get("accept", ""), "method": request.method, }, diff --git a/tests/integration/security/test_authorization.py b/tests/integration/security/test_authorization.py index c1cc7ee1..1e777060 100644 --- a/tests/integration/security/test_authorization.py +++ b/tests/integration/security/test_authorization.py @@ -48,3 +48,65 @@ class TestAuthorization: ) # Admin should be able to view vendor assert response.status_code == 200 + + +@pytest.mark.integration +@pytest.mark.security +@pytest.mark.auth +class TestHTMLPageAuthRedirect: + """ + Test that authorization errors on HTML pages redirect to login. + + For HTML page requests (Accept: text/html), both 401 and specific 403 errors + should redirect to the appropriate login page instead of showing error pages. + """ + + def test_api_request_returns_json_on_auth_error(self, client, auth_headers): + """Test that API requests return JSON error, not redirect.""" + # Non-admin user trying to access admin API endpoint + response = client.get("/api/v1/admin/users", headers=auth_headers) + assert response.status_code == 403 + # Should be JSON, not redirect + data = response.json() + assert data["error_code"] == "ADMIN_REQUIRED" + + def test_html_page_redirects_on_no_token(self, client): + """Test that HTML page requests without token redirect to login.""" + # Request admin page without token, accepting HTML + response = client.get( + "/admin/dashboard", + headers={"Accept": "text/html"}, + follow_redirects=False, + ) + # Should redirect (302) to login page + assert response.status_code == 302 + assert "/admin/login" in response.headers.get("location", "") + + def test_html_page_redirects_on_invalid_token(self, client): + """Test that HTML page requests with invalid token redirect to login.""" + # Request admin page with invalid token cookie, accepting HTML + response = client.get( + "/admin/dashboard", + headers={"Accept": "text/html"}, + cookies={"admin_token": "invalid.token.here"}, + follow_redirects=False, + ) + # Should redirect (302) to login page + assert response.status_code == 302 + assert "/admin/login" in response.headers.get("location", "") + + def test_html_page_redirects_on_admin_required(self, client, auth_headers): + """Test that HTML page requests with wrong role redirect to login.""" + # Regular user (not admin) trying to access admin HTML page + # We need to set both the cookie and Accept header for HTML behavior + response = client.get( + "/admin/dashboard", + headers={ + "Accept": "text/html", + "Authorization": auth_headers["Authorization"], + }, + follow_redirects=False, + ) + # Should redirect (302) to login page when user lacks admin role + assert response.status_code == 302 + assert "/admin/login" in response.headers.get("location", "")