diff --git a/tests/test_connexion_extractors.py b/tests/test_connexion_extractors.py new file mode 100644 index 0000000..35db8cf --- /dev/null +++ b/tests/test_connexion_extractors.py @@ -0,0 +1,82 @@ +"""Request and response info extraction for the connexion backend (issue #137).""" +from types import SimpleNamespace + +import pytest +from helpers.imports import undo_imports_from_package + + +@pytest.fixture +def connexion_extractors(): + import json_logging + from json_logging.framework import connexion as connexion_framework + + yield json_logging, connexion_framework + undo_imports_from_package("json_logging") + + +def _request(**overrides): + values = dict( + authorization=None, + headers={"X-Present": "yes"}, + environ={"SERVER_PROTOCOL": "HTTP/1.1", "REMOTE_PORT": "5555"}, + path="/hello", + content_length=12, + method="POST", + remote_addr="10.0.0.1", + ) + values.update(overrides) + return SimpleNamespace(**values) + + +def test_init_request_instrument_rejects_non_connexion_app(connexion_extractors): + json_logging, _ = connexion_extractors + json_logging.init_connexion() + + with pytest.raises(RuntimeError, match="not a valid connexion"): + json_logging.init_request_instrument(SimpleNamespace(app=object())) + + +def test_remote_user_defaults_to_empty_value(connexion_extractors): + json_logging, framework = connexion_extractors + + assert framework.ConnexionRequestInfoExtractor().get_remote_user(_request()) == json_logging.EMPTY_VALUE + + +def test_remote_user_is_the_basic_auth_username(connexion_extractors): + _, framework = connexion_extractors + request = _request(authorization=SimpleNamespace(username="alice")) + + assert framework.ConnexionRequestInfoExtractor().get_remote_user(request) == "alice" + + +def test_header_lookup_falls_back_to_default(connexion_extractors): + _, framework = connexion_extractors + extractor = framework.ConnexionRequestInfoExtractor() + + assert extractor.get_http_header(_request(), "X-Present") == "yes" + assert extractor.get_http_header(_request(), "X-Missing", "fallback") == "fallback" + assert extractor.get_http_header(object(), "X-Missing", "fallback") == "fallback" + + +def test_request_fields_are_read_from_the_request(connexion_extractors): + _, framework = connexion_extractors + extractor = framework.ConnexionRequestInfoExtractor() + request = _request() + + assert extractor.get_protocol(request) == "HTTP/1.1" + assert extractor.get_path(request) == "/hello" + assert extractor.get_content_length(request) == 12 + assert extractor.get_method(request) == "POST" + assert extractor.get_remote_ip(request) == "10.0.0.1" + assert extractor.get_remote_port(request) == "5555" + + +def test_response_fields_are_read_from_the_response(connexion_extractors): + _, framework = connexion_extractors + extractor = framework.ConnexionResponseInfoExtractor() + response = SimpleNamespace( + status_code=201, calculate_content_length=lambda: 42, content_type="application/json") + + assert extractor.get_status_code(response) == 201 + assert extractor.get_response_size(response) == 42 + assert extractor.get_content_type(response) == "application/json" diff --git a/tests/test_flask_extractors.py b/tests/test_flask_extractors.py new file mode 100644 index 0000000..f48d583 --- /dev/null +++ b/tests/test_flask_extractors.py @@ -0,0 +1,94 @@ +"""Request and response info extraction for the flask and connexion backends (issue #137).""" +import base64 +import json +import logging +from types import SimpleNamespace + +import flask +import pytest +from helpers.handler import FormattedMessageCollectorHandler +from helpers.imports import undo_imports_from_package + + +@pytest.fixture +def flask_app_and_request_handler(): + import json_logging + from json_logging.formatters import JSONRequestLogFormatter + + app = flask.Flask(__name__) + request_logger = logging.getLogger("flask-request-logger") + handler = FormattedMessageCollectorHandler() + handler.setFormatter(JSONRequestLogFormatter()) + + json_logging.init_flask(enable_json=True) + json_logging.init_request_instrument(app) + request_logger.addHandler(handler) + + @app.route("/hello") + def hello(): + return {"hello": "world"} + + yield app, handler + + request_logger.removeHandler(handler) + undo_imports_from_package("json_logging") + + +def test_flask_request_log_has_request_and_response_details(flask_app_and_request_handler): + app, handler = flask_app_and_request_handler + + response = app.test_client().get("/hello", headers={"X-Correlation-Id": "abc-def"}) + + assert response.status_code == 200 + msg = json.loads(handler.messages[0]) + assert msg["method"] == "GET" + assert msg["request"] == "/hello" + assert msg["protocol"] == "HTTP/1.1" + assert msg["correlation_id"] == "abc-def" + assert msg["response_status"] == 200 + assert msg["response_content_type"] == "application/json" + assert msg["response_size_b"] == len(response.data) + assert msg["remote_user"] == "-" + + +def test_flask_request_log_has_basic_auth_user(flask_app_and_request_handler): + app, handler = flask_app_and_request_handler + token = base64.b64encode(b"alice:secret").decode() + + app.test_client().get("/hello", headers={"Authorization": "Basic " + token}) + + assert json.loads(handler.messages[0])["remote_user"] == "alice" + + +def test_flask_init_request_instrument_rejects_non_flask_app(flask_app_and_request_handler): + import json_logging + + with pytest.raises(RuntimeError, match="not a valid flask"): + json_logging.init_request_instrument(object()) + + +def test_flask_extractor_outside_request_context_has_no_current_request(flask_app_and_request_handler): + from json_logging.framework.flask import FlaskRequestInfoExtractor + + assert FlaskRequestInfoExtractor.get_current_request() is None + + +def test_flask_extractor_header_lookup_falls_back_to_default(flask_app_and_request_handler): + from json_logging.framework.flask import FlaskRequestInfoExtractor + + extractor = FlaskRequestInfoExtractor() + request = SimpleNamespace(headers={"X-Present": "yes"}) + + assert extractor.get_http_header(request, "X-Present") == "yes" + assert extractor.get_http_header(request, "X-Missing", "fallback") == "fallback" + # an object without usable headers must not raise + assert extractor.get_http_header(object(), "X-Missing", "fallback") == "fallback" + + +def test_flask_extractor_correlation_id_outside_request_context_is_none(flask_app_and_request_handler): + from json_logging.framework.flask import FlaskRequestInfoExtractor + + extractor = FlaskRequestInfoExtractor() + + extractor.set_correlation_id(None, "ignored") # no app context: must not raise + assert extractor.get_correlation_id_in_request_context(None) is None