diff --git a/app.py b/app.py index f5e5a74..94f8063 100644 --- a/app.py +++ b/app.py @@ -4,21 +4,28 @@ # Responsibilities: # - Create the Flask app instance # - Register the main Blueprint from routes/ -# - Register error handlers +# - Register the global error boundary via errors/handlers.py # - Start the development server when run directly # # Business logic, recommendation scoring, and data loading all live in # the utils/ and routes/ packages, not here. -from flask import Flask, render_template +from flask import Flask from routes.main_routes import main from config import Config +from errors.handlers import register_error_handlers app = Flask(__name__) # Register all routes defined in the main Blueprint app.register_blueprint(main) +# Register the global error boundary (handles 400, 403, 404, 405, 429, 500, +# and any unhandled Exception). Must be called after Blueprint registration +# so Blueprint-level error handlers take precedence where defined. +register_error_handlers(app) + + @app.after_request def add_security_headers(response): """Add basic security headers to all responses.""" @@ -30,31 +37,20 @@ def add_security_headers(response): ) return response -# ---- Error handlers ---- - -@app.errorhandler(404) -def page_not_found(error): - """Render a friendly 404 page instead of the raw Flask error.""" - return render_template("404.html", config=Config), 404 - -@app.errorhandler(500) +# Expose the 500 handler at module level so existing tests can import it +# directly: from app import app, internal_server_error def internal_server_error(error): - """Render a friendly 500 page for unexpected server errors.""" - return render_template("500.html", config=Config), 500 - -@app.errorhandler(405) -def method_not_allowed(error): - """Render a friendly 405 page when the wrong HTTP method is used.""" - return render_template("405.html", config=Config), 405 - -@app.errorhandler(403) -def forbidden(error): - """Render a friendly 403 page when access is denied.""" - return render_template("403.html", config=Config), 403 + """Proxy kept for backward compatibility with test_basic.py.""" + from errors.handlers import internal_server_error as _handler + return _handler(error) if __name__ == "__main__": import os debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() in ("true", "1") - app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug_mode) + app.run( + host="0.0.0.0", + port=int(os.environ.get("PORT", 5000)), + debug=debug_mode, + ) diff --git a/errors/__init__.py b/errors/__init__.py new file mode 100644 index 0000000..6b65cde --- /dev/null +++ b/errors/__init__.py @@ -0,0 +1 @@ +# errors/__init__.py \ No newline at end of file diff --git a/errors/handlers.py b/errors/handlers.py new file mode 100644 index 0000000..0dd1897 --- /dev/null +++ b/errors/handlers.py @@ -0,0 +1,115 @@ +# errors/handlers.py +# Global error handling for DevPath. +# +# All Flask error handlers live here so app.py stays lean. +# Each handler: +# 1. Logs the exception via utils/error_logger.py (server-side only). +# 2. Renders the matching HTML template with a safe, user-friendly message. +# 3. Never leaks stack traces, file paths, or internal state to the client. +# +# Register by calling register_error_handlers(app) from app.py. + +from flask import Flask, render_template, request, jsonify +from config import Config +from utils.error_logger import log_exception + + +def _wants_json() -> bool: + """Return True when the request prefers a JSON response. + + API routes (prefixed /api/) always receive JSON error responses. + Browser requests receive the HTML error pages. + """ + if request.path.startswith("/api/"): + return True + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + return best == "application/json" + + +def _json_error(status_code: int, message: str, correlation_id: str = ""): + """Build a consistent JSON error envelope.""" + body = {"error": message} + if correlation_id: + body["reference"] = correlation_id + return jsonify(body), status_code + + +# --------------------------------------------------------------------------- +# Standalone handler functions — importable directly for testing +# --------------------------------------------------------------------------- + +def bad_request(error): + """Handle 400 Bad Request.""" + correlation_id = log_exception(error, status_code=400, context="bad_request") + if _wants_json(): + return _json_error(400, "Bad request.", correlation_id) + return render_template("400.html", config=Config, reference=correlation_id), 400 + + +def forbidden(error): + """Handle 403 Forbidden.""" + correlation_id = log_exception(error, status_code=403, context="forbidden") + if _wants_json(): + return _json_error(403, "Access denied.", correlation_id) + return render_template("403.html", config=Config, reference=correlation_id), 403 + + +def page_not_found(error): + """Handle 404 Not Found.""" + correlation_id = log_exception(error, status_code=404, context="page_not_found") + if _wants_json(): + return _json_error(404, "The requested resource was not found.", correlation_id) + return render_template("404.html", config=Config, reference=correlation_id), 404 + + +def method_not_allowed(error): + """Handle 405 Method Not Allowed.""" + correlation_id = log_exception(error, status_code=405, context="method_not_allowed") + if _wants_json(): + return _json_error(405, "HTTP method not allowed.", correlation_id) + return render_template("405.html", config=Config, reference=correlation_id), 405 + + +def too_many_requests(error): + """Handle 429 Too Many Requests.""" + correlation_id = log_exception(error, status_code=429, context="too_many_requests") + if _wants_json(): + return _json_error(429, "Too many requests. Please slow down.", correlation_id) + return render_template("429.html", config=Config, reference=correlation_id), 429 + + +def internal_server_error(error): + """Handle 500 Internal Server Error.""" + correlation_id = log_exception(error, status_code=500, context="internal_server_error") + if _wants_json(): + return _json_error(500, "An unexpected error occurred.", correlation_id) + return render_template("500.html", config=Config, reference=correlation_id), 500 + + +def unhandled_exception(error): + """Catch-all for any Exception not matched by a specific handler.""" + correlation_id = log_exception(error, status_code=500, context="unhandled_exception") + if _wants_json(): + return _json_error(500, "An unexpected error occurred.", correlation_id) + return render_template("500.html", config=Config, reference=correlation_id), 500 + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def register_error_handlers(app: Flask) -> None: + """Attach all global error handlers to the Flask application. + + Call once during application initialisation: + + from errors.handlers import register_error_handlers + register_error_handlers(app) + """ + app.register_error_handler(400, bad_request) + app.register_error_handler(403, forbidden) + app.register_error_handler(404, page_not_found) + app.register_error_handler(405, method_not_allowed) + app.register_error_handler(429, too_many_requests) + app.register_error_handler(500, internal_server_error) + app.register_error_handler(Exception, unhandled_exception) diff --git a/templates/400.html b/templates/400.html new file mode 100644 index 0000000..1697d60 --- /dev/null +++ b/templates/400.html @@ -0,0 +1,38 @@ + + + + + + Bad Request — DevPath + + + + + + + + + + + + + +
+
+
400
+

Bad Request

+

The request could not be understood by the server. Please check your input and try again.

+ {% if reference %} +

Reference: {{ reference }}

+ {% endif %} + Back to Home +
+
+ + + diff --git a/templates/429.html b/templates/429.html new file mode 100644 index 0000000..775c2fb --- /dev/null +++ b/templates/429.html @@ -0,0 +1,38 @@ + + + + + + Too Many Requests — DevPath + + + + + + + + + + + + + +
+
+
429
+

Too Many Requests

+

You have sent too many requests in a short period. Please wait a moment before trying again.

+ {% if reference %} +

Reference: {{ reference }}

+ {% endif %} + Back to Home +
+
+ + + diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..29d953d --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,273 @@ +# tests/test_error_handling.py +# Tests for the global error boundary and exception handling mechanism. +# +# Covers: +# - Every registered HTTP error handler (400, 403, 404, 405, 429, 500) +# - JSON vs HTML content negotiation +# - API routes always receive JSON error responses +# - Correlation ID is present in JSON error envelopes +# - Stack traces / internal details are never exposed in any response +# - Security headers are preserved on error responses +# - log_exception() returns a valid correlation ID and does not raise +# - Unhandled exceptions are caught and return 500 + +import sys +import os +import logging + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app import app +from utils.error_logger import log_exception + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def get_client(): + app.config["TESTING"] = True + return app.test_client() + + +# --------------------------------------------------------------------------- +# log_exception unit tests +# --------------------------------------------------------------------------- + +def test_log_exception_returns_correlation_id(): + """log_exception must return a non-empty string.""" + cid = log_exception(Exception("boom"), status_code=500) + assert isinstance(cid, str) + assert len(cid) > 0 + + +def test_log_exception_short_id(): + """Correlation ID should be exactly 8 hex characters.""" + cid = log_exception(ValueError("oops"), status_code=400) + assert len(cid) == 8 + assert all(c in "0123456789abcdef" for c in cid) + + +def test_log_exception_unique_ids(): + """Each call must produce a distinct correlation ID.""" + ids = {log_exception(Exception("x"), status_code=500) for _ in range(10)} + assert len(ids) == 10 + + +def test_log_exception_none_exc_does_not_raise(): + """Passing None as the exception must not raise.""" + cid = log_exception(None, status_code=500) + assert isinstance(cid, str) + + +def test_log_exception_writes_to_logger(caplog): + """log_exception must emit at least one ERROR-level log record.""" + with caplog.at_level(logging.ERROR, logger="devpath.errors"): + log_exception(RuntimeError("test log"), status_code=500, context="unit_test") + assert len(caplog.records) >= 1 + assert caplog.records[-1].levelno == logging.ERROR + + +def test_log_exception_includes_status_code(caplog): + """The log record message must contain the HTTP status code.""" + with caplog.at_level(logging.ERROR, logger="devpath.errors"): + log_exception(Exception("check"), status_code=418, context="teapot") + assert any("418" in r.message for r in caplog.records) + + +def test_log_exception_does_not_expose_traceback_to_caller(): + """The return value must be a short ID, not a traceback string.""" + cid = log_exception(Exception("secret internal detail"), status_code=500) + assert "Traceback" not in cid + assert "secret" not in cid + + +# --------------------------------------------------------------------------- +# HTML error page tests (browser requests) +# --------------------------------------------------------------------------- + +def _trigger_404(client): + return client.get("/this-route-definitely-does-not-exist-xyz") + + +def test_404_returns_404_status(): + assert _trigger_404(get_client()).status_code == 404 + + +def test_404_html_contains_friendly_text(): + response = _trigger_404(get_client()) + assert b"404" in response.data + + +def test_404_does_not_expose_stack_trace(): + response = _trigger_404(get_client()) + assert b"Traceback" not in response.data + assert b"File " not in response.data + + +def test_500_html_renders(): + """The 500 handler must render the friendly template and return 500.""" + with app.app_context(): + from errors.handlers import internal_server_error + rendered, status = internal_server_error(Exception("test")) + assert status == 500 + assert b"Internal Server Error" in rendered + assert b"Back to Home" in rendered + + +def test_500_does_not_expose_exception_message(): + """The 500 HTML response must not contain the raw exception message.""" + with app.app_context(): + from errors.handlers import internal_server_error + rendered, _ = internal_server_error(Exception("super secret db password")) + assert b"super secret db password" not in rendered + + +def test_405_on_wrong_method(): + client = get_client() + # GET on a POST-only endpoint + response = client.get("/api/recommend") + assert response.status_code == 405 + + +# --------------------------------------------------------------------------- +# JSON error response tests (API routes) +# --------------------------------------------------------------------------- + +def test_api_404_returns_json(): + client = get_client() + response = client.get("/api/nonexistent-endpoint-xyz") + assert response.status_code == 404 + data = response.get_json() + assert data is not None + assert "error" in data + + +def test_api_404_json_has_no_traceback(): + client = get_client() + data = client.get("/api/nonexistent-endpoint-xyz").get_json() + assert "Traceback" not in str(data) + assert "File " not in str(data) + + +def test_api_405_returns_json(): + client = get_client() + response = client.get("/api/recommend") + assert response.status_code == 405 + data = response.get_json() + assert "error" in data + + +def test_api_error_json_has_correlation_id(): + """JSON error responses for API routes must include a 'reference' field.""" + client = get_client() + data = client.get("/api/nonexistent-endpoint-xyz").get_json() + assert "reference" in data + assert len(data["reference"]) == 8 + + +def test_api_error_envelope_shape(): + """JSON error envelope must have exactly 'error' and 'reference' keys.""" + client = get_client() + data = client.get("/api/nonexistent-endpoint-xyz").get_json() + assert set(data.keys()) == {"error", "reference"} + + +def test_api_missing_field_returns_400_json(): + """POST /api/recommend with missing field must return 400 JSON.""" + client = get_client() + response = client.post("/api/recommend", json={"skills": "", "level": "Beginner", "interest": "Data", "time": "Low"}) + assert response.status_code in (400, 415) + data = response.get_json() + assert "error" in data + + +# --------------------------------------------------------------------------- +# Content negotiation tests +# --------------------------------------------------------------------------- + +def test_html_accept_returns_html_for_404(): + """A browser Accept header must receive an HTML error page.""" + client = get_client() + response = client.get( + "/this-route-does-not-exist", + headers={"Accept": "text/html,application/xhtml+xml,*/*;q=0.9"}, + ) + assert response.status_code == 404 + assert b"" in response.data or b" str: + """Log an exception securely and return a correlation ID. + + The full traceback is written to the server log only — it is never + included in any HTTP response. Callers receive a short correlation ID + they can surface in the UI ("reference: ") so users can report + issues without exposing internals. + + Args: + exc: The exception instance (may be None for synthetic errors). + status_code: HTTP status code associated with this error. + context: Optional free-text label (e.g. the route name). + + Returns: + A short alphanumeric correlation ID string. + """ + correlation_id = uuid.uuid4().hex[:8] + + exc_type = type(exc).__name__ if exc is not None else "UnknownError" + tb = ( + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + if exc is not None + else "No traceback available" + ) + + logger.error( + "status=%d id=%s type=%s context=%r\n%s", + status_code, + correlation_id, + exc_type, + context or "—", + tb, + ) + + return correlation_id