This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch goal-scan
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git
The following commit(s) were added to refs/heads/goal-scan by this push:
new c8878bff fix(review): address PR review comments
c8878bff is described below
commit c8878bff56809ba10ae1515a7b7b9eb3ffa43532
Author: imbajin <[email protected]>
AuthorDate: Wed Jun 3 11:16:31 2026 +0800
fix(review): address PR review comments
- make RAG request client_config scoped and restored after use
- avoid eager request redaction when debug logging is disabled
- preserve explicit response validation errors and simplify HTTPError flow
- clean admin error control flow and cross-platform path assertion
- expose empty Gremlin payloads instead of silently returning None
---
.workflow/code-scan/checkpoints/00-setup.md | 2 +-
hugegraph-llm/src/hugegraph_llm/api/admin_api.py | 5 +-
hugegraph-llm/src/hugegraph_llm/api/rag_api.py | 224 +++++++++++----------
hugegraph-llm/src/tests/api/test_admin_api.py | 3 +-
hugegraph-llm/src/tests/api/test_rag_api.py | 21 +-
.../src/pyhugegraph/api/gremlin.py | 3 +-
.../src/pyhugegraph/utils/huge_requests.py | 13 +-
.../src/pyhugegraph/utils/util.py | 31 ++-
.../src/tests/api/test_auth_routing.py | 31 ++-
.../src/tests/api/test_gremlin.py | 8 +
.../src/tests/api/test_response_validation.py | 40 +++-
11 files changed, 254 insertions(+), 127 deletions(-)
diff --git a/.workflow/code-scan/checkpoints/00-setup.md
b/.workflow/code-scan/checkpoints/00-setup.md
index 563eda77..520d2505 100644
--- a/.workflow/code-scan/checkpoints/00-setup.md
+++ b/.workflow/code-scan/checkpoints/00-setup.md
@@ -2,7 +2,7 @@
## Repository State
-- CWD: `/Users/imbajin/github/graph/ai`
+- CWD: repository root
- Branch: `goal-test`
- Start SHA: `d16db33c5509647afccae588c46c0b0767adbb76`
- Existing unrelated untracked path observed before edits:
`.workflow/pr68-review/`
diff --git a/hugegraph-llm/src/hugegraph_llm/api/admin_api.py
b/hugegraph-llm/src/hugegraph_llm/api/admin_api.py
index 87abc063..294cb8e8 100644
--- a/hugegraph-llm/src/hugegraph_llm/api/admin_api.py
+++ b/hugegraph-llm/src/hugegraph_llm/api/admin_api.py
@@ -49,7 +49,6 @@ def _resolve_log_path(log_file: str | None) -> str:
return os.path.join(LOG_DIR, log_file)
-# FIXME: line 31: E0702: Raising dict while only classes or instances are
allowed (raising-bad-type)
def admin_http_api(router: APIRouter, log_stream):
@router.post("/logs", status_code=status.HTTP_200_OK)
async def log_stream_api(req: LogStreamRequest):
@@ -59,9 +58,9 @@ def admin_http_api(router: APIRouter, log_stream):
detail="Admin token is not configured securely.",
)
if admin_settings.admin_token != req.admin_token:
- raise generate_response( # pylint: disable=raising-bad-type
+ return generate_response(
RAGResponse(
- status_code=status.HTTP_403_FORBIDDEN, # pylint:
disable=E0702
+ status_code=status.HTTP_403_FORBIDDEN,
message="Invalid admin_token",
)
)
diff --git a/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
b/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
index d57f2812..0e76ca50 100644
--- a/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
+++ b/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
@@ -16,6 +16,8 @@
# under the License.
import json
+from contextlib import contextmanager
+from threading import RLock
from fastapi import APIRouter, HTTPException, status
@@ -33,6 +35,15 @@ from hugegraph_llm.config import huge_settings,
llm_settings, prompt
from hugegraph_llm.utils.graph_index_utils import get_vertex_details
from hugegraph_llm.utils.log import log
+_GRAPH_CONFIG_FIELD_MAP = {
+ "url": "graph_url",
+ "graph": "graph_name",
+ "user": "graph_user",
+ "pwd": "graph_pwd",
+ "gs": "graph_space",
+}
+_GRAPH_CONFIG_LOCK = RLock()
+
# pylint: disable=too-many-statements
def rag_http_api(
@@ -45,69 +56,27 @@ def rag_http_api(
apply_reranker_conf,
gremlin_generate_selective_func,
):
+ @contextmanager
+ def request_graph_config(req):
+ with _GRAPH_CONFIG_LOCK:
+ original_values = {
+ settings_field: getattr(huge_settings, settings_field)
+ for settings_field in _GRAPH_CONFIG_FIELD_MAP.values()
+ }
+ try:
+ client_config = getattr(req, "client_config", None)
+ if client_config is not None:
+ for request_field, settings_field in
_GRAPH_CONFIG_FIELD_MAP.items():
+ if request_field in client_config.model_fields_set:
+ setattr(huge_settings, settings_field,
getattr(client_config, request_field))
+ yield
+ finally:
+ for settings_field, original_value in original_values.items():
+ setattr(huge_settings, settings_field, original_value)
+
@router.post("/rag", status_code=status.HTTP_200_OK)
def rag_answer_api(req: RAGRequest):
- set_graph_config(req)
-
- # Basic parameter validation: empty query => 400
- if not req.query or not str(req.query).strip():
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Query must not be empty.",
- )
-
- result = rag_answer_func(
- text=req.query,
- raw_answer=req.raw_answer,
- vector_only_answer=req.vector_only,
- graph_only_answer=req.graph_only,
- graph_vector_answer=req.graph_vector_answer,
- graph_ratio=req.graph_ratio,
- rerank_method=req.rerank_method,
- near_neighbor_first=req.near_neighbor_first,
- gremlin_tmpl_num=req.gremlin_tmpl_num,
- max_graph_items=req.max_graph_items,
- topk_return_results=req.topk_return_results,
- vector_dis_threshold=req.vector_dis_threshold,
- topk_per_keyword=req.topk_per_keyword,
- # Keep prompt params in the end
- custom_related_information=req.custom_priority_info,
- answer_prompt=req.answer_prompt or prompt.answer_prompt,
- keywords_extract_prompt=req.keywords_extract_prompt or
prompt.keywords_extract_prompt,
- gremlin_prompt=req.gremlin_prompt or
prompt.gremlin_generate_prompt,
- )
- # TODO: we need more info in the response for users to understand the
query logic
- return {
- "query": req.query,
- **{
- key: value
- for key, value in zip(
- ["raw_answer", "vector_only", "graph_only",
"graph_vector_answer"],
- result,
- )
- if getattr(req, key)
- },
- }
-
- def set_graph_config(req):
- if req.client_config is None:
- return
- field_map = {
- "url": "graph_url",
- "graph": "graph_name",
- "user": "graph_user",
- "pwd": "graph_pwd",
- "gs": "graph_space",
- }
- for request_field, settings_field in field_map.items():
- if request_field in req.client_config.model_fields_set:
- setattr(huge_settings, settings_field,
getattr(req.client_config, request_field))
-
- @router.post("/rag/graph", status_code=status.HTTP_200_OK)
- def graph_rag_recall_api(req: GraphRAGRequest):
- try:
- set_graph_config(req)
-
+ with request_graph_config(req):
# Basic parameter validation: empty query => 400
if not req.query or not str(req.query).strip():
raise HTTPException(
@@ -115,39 +84,85 @@ def rag_http_api(
detail="Query must not be empty.",
)
- result = graph_rag_recall_func(
- query=req.query,
+ result = rag_answer_func(
+ text=req.query,
+ raw_answer=req.raw_answer,
+ vector_only_answer=req.vector_only,
+ graph_only_answer=req.graph_only,
+ graph_vector_answer=req.graph_vector_answer,
+ graph_ratio=req.graph_ratio,
+ rerank_method=req.rerank_method,
+ near_neighbor_first=req.near_neighbor_first,
+ gremlin_tmpl_num=req.gremlin_tmpl_num,
max_graph_items=req.max_graph_items,
topk_return_results=req.topk_return_results,
vector_dis_threshold=req.vector_dis_threshold,
topk_per_keyword=req.topk_per_keyword,
- gremlin_tmpl_num=req.gremlin_tmpl_num,
- rerank_method=req.rerank_method,
- near_neighbor_first=req.near_neighbor_first,
+ # Keep prompt params in the end
custom_related_information=req.custom_priority_info,
+ answer_prompt=req.answer_prompt or prompt.answer_prompt,
+ keywords_extract_prompt=req.keywords_extract_prompt or
prompt.keywords_extract_prompt,
gremlin_prompt=req.gremlin_prompt or
prompt.gremlin_generate_prompt,
- get_vertex_only=req.get_vertex_only,
)
+ # TODO: we need more info in the response for users to understand
the query logic
+ return {
+ "query": req.query,
+ **{
+ key: value
+ for key, value in zip(
+ ["raw_answer", "vector_only", "graph_only",
"graph_vector_answer"],
+ result,
+ )
+ if getattr(req, key)
+ },
+ }
- if req.get_vertex_only:
- vertex_details = get_vertex_details(result["match_vids"],
result)
- if vertex_details:
- result["match_vids"] = vertex_details
-
- if isinstance(result, dict):
- params = [
- "query",
- "keywords",
- "match_vids",
- "graph_result_flag",
- "gremlin",
- "graph_result",
- "vertex_degree_list",
- ]
- user_result = {key: result[key] for key in params if key in
result}
- return {"graph_recall": user_result}
- return {"graph_recall": json.dumps(result)}
+ @router.post("/rag/graph", status_code=status.HTTP_200_OK)
+ def graph_rag_recall_api(req: GraphRAGRequest):
+ try:
+ with request_graph_config(req):
+ # Basic parameter validation: empty query => 400
+ if not req.query or not str(req.query).strip():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Query must not be empty.",
+ )
+
+ result = graph_rag_recall_func(
+ query=req.query,
+ max_graph_items=req.max_graph_items,
+ topk_return_results=req.topk_return_results,
+ vector_dis_threshold=req.vector_dis_threshold,
+ topk_per_keyword=req.topk_per_keyword,
+ gremlin_tmpl_num=req.gremlin_tmpl_num,
+ rerank_method=req.rerank_method,
+ near_neighbor_first=req.near_neighbor_first,
+ custom_related_information=req.custom_priority_info,
+ gremlin_prompt=req.gremlin_prompt or
prompt.gremlin_generate_prompt,
+ get_vertex_only=req.get_vertex_only,
+ )
+ if req.get_vertex_only:
+ vertex_details = get_vertex_details(result["match_vids"],
result)
+ if vertex_details:
+ result["match_vids"] = vertex_details
+
+ if isinstance(result, dict):
+ params = [
+ "query",
+ "keywords",
+ "match_vids",
+ "graph_result_flag",
+ "gremlin",
+ "graph_result",
+ "vertex_degree_list",
+ ]
+ user_result = {key: result[key] for key in params if key
in result}
+ return {"graph_recall": user_result}
+ return {"graph_recall": json.dumps(result)}
+
+ except HTTPException as e:
+ raise e
except TypeError as e:
log.error("TypeError in graph_rag_recall_api: %s", e)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)) from e
@@ -208,27 +223,26 @@ def rag_http_api(
@router.post("/text2gremlin", status_code=status.HTTP_200_OK)
def text2gremlin_api(req: GremlinGenerateRequest):
try:
- set_graph_config(req)
-
- # Basic parameter validation: empty query => 400
- if not req.query or not str(req.query).strip():
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Query must not be empty.",
+ with request_graph_config(req):
+ # Basic parameter validation: empty query => 400
+ if not req.query or not str(req.query).strip():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Query must not be empty.",
+ )
+
+ output_types_str_list = None
+ if req.output_types:
+ output_types_str_list = [ot.value for ot in
req.output_types]
+
+ response_dict = gremlin_generate_selective_func(
+ inp=req.query,
+ example_num=req.example_num,
+ schema_input=huge_settings.graph_name,
+ gremlin_prompt_input=req.gremlin_prompt,
+ requested_outputs=output_types_str_list,
)
-
- output_types_str_list = None
- if req.output_types:
- output_types_str_list = [ot.value for ot in req.output_types]
-
- response_dict = gremlin_generate_selective_func(
- inp=req.query,
- example_num=req.example_num,
- schema_input=huge_settings.graph_name,
- gremlin_prompt_input=req.gremlin_prompt,
- requested_outputs=output_types_str_list,
- )
- return response_dict
+ return response_dict
except HTTPException as e:
raise e
except Exception as e:
diff --git a/hugegraph-llm/src/tests/api/test_admin_api.py
b/hugegraph-llm/src/tests/api/test_admin_api.py
index 4032c78c..ee3c0a5f 100644
--- a/hugegraph-llm/src/tests/api/test_admin_api.py
+++ b/hugegraph-llm/src/tests/api/test_admin_api.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import os
from unittest.mock import Mock
import pytest
@@ -108,4 +109,4 @@ def
test_logs_streams_valid_log_file_under_logs_dir(monkeypatch):
)
assert response.status_code == status.HTTP_200_OK
- log_stream.assert_called_once_with("logs/llm-server.log")
+ log_stream.assert_called_once_with(os.path.join("logs", "llm-server.log"))
diff --git a/hugegraph-llm/src/tests/api/test_rag_api.py
b/hugegraph-llm/src/tests/api/test_rag_api.py
index e95bc5ec..7b05d70f 100644
--- a/hugegraph-llm/src/tests/api/test_rag_api.py
+++ b/hugegraph-llm/src/tests/api/test_rag_api.py
@@ -181,7 +181,17 @@ def
test_rag_client_config_updates_only_explicit_graph_fields(monkeypatch):
monkeypatch.setattr(huge_settings, "graph_user", "original_user")
monkeypatch.setattr(huge_settings, "graph_pwd", "original_pwd")
monkeypatch.setattr(huge_settings, "graph_space", "original_space")
- client, callbacks = _make_test_client()
+ observed_settings = {}
+
+ def rag_answer_func(**_kwargs):
+ observed_settings["graph_url"] = huge_settings.graph_url
+ observed_settings["graph_name"] = huge_settings.graph_name
+ observed_settings["graph_user"] = huge_settings.graph_user
+ observed_settings["graph_pwd"] = huge_settings.graph_pwd
+ observed_settings["graph_space"] = huge_settings.graph_space
+ return ("raw", "vector", "graph", "graph_vector")
+
+ client, callbacks =
_make_test_client(rag_answer_func=Mock(side_effect=rag_answer_func))
response = client.post(
"/rag",
@@ -195,7 +205,14 @@ def
test_rag_client_config_updates_only_explicit_graph_fields(monkeypatch):
assert response.status_code == status.HTTP_200_OK
callbacks["rag_answer_func"].assert_called_once()
- assert huge_settings.graph_url == "http://override:8080"
+ assert observed_settings == {
+ "graph_url": "http://override:8080",
+ "graph_name": "original_graph",
+ "graph_user": "original_user",
+ "graph_pwd": "original_pwd",
+ "graph_space": "original_space",
+ }
+ assert huge_settings.graph_url == "http://original:8080"
assert huge_settings.graph_name == "original_graph"
assert huge_settings.graph_user == "original_user"
assert huge_settings.graph_pwd == "original_pwd"
diff --git a/hugegraph-python-client/src/pyhugegraph/api/gremlin.py
b/hugegraph-python-client/src/pyhugegraph/api/gremlin.py
index d738410c..9875b075 100644
--- a/hugegraph-python-client/src/pyhugegraph/api/gremlin.py
+++ b/hugegraph-python-client/src/pyhugegraph/api/gremlin.py
@@ -44,7 +44,8 @@ class GremlinManager(HugeParamsBase):
"g": f"__g_{self._sess.cfg.graph_name}",
}
- if response := self._invoke_request(data=gremlin_data.to_json()):
+ response = self._invoke_request(data=gremlin_data.to_json())
+ if response is not None:
return ResponseData(response).result
log.error("Gremlin can't get results: %s", str(response))
return None
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py
b/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py
index 8fb933df..91d086b7 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py
@@ -16,6 +16,7 @@
# under the License.
+import logging
from typing import Any
from urllib.parse import urljoin
@@ -149,7 +150,13 @@ class HGraphSession:
timeout=self._timeout,
**kwargs,
)
- log.debug( # pylint: disable=logging-fstring-interpolation
- f"Request: {method} {url} validator={validator}
kwargs={redact_sensitive_data(kwargs)} {response}"
- )
+ if log.isEnabledFor(logging.DEBUG):
+ log.debug(
+ "Request: %s %s validator=%s kwargs=%s %s",
+ method,
+ url,
+ validator,
+ redact_sensitive_data(kwargs),
+ response,
+ )
return validator(response, method=method, path=path)
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/util.py
b/hugegraph-python-client/src/pyhugegraph/utils/util.py
index 9cab72ff..5a710ff0 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/util.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/util.py
@@ -41,6 +41,8 @@ SENSITIVE_KEY_PARTS = (
"secret",
"token",
)
+ESCAPE_MARKERS = ("\\u", "\\U", "\\x", "\\n", "\\r", "\\t")
+RESPONSE_CONTENT_TYPES = {"raw", "json", "text"}
def _is_sensitive_key(key) -> bool:
@@ -48,6 +50,19 @@ def _is_sensitive_key(key) -> bool:
return any(part in key_lower for part in SENSITIVE_KEY_PARTS)
+def _may_contain_sensitive_key(value: str) -> bool:
+ value_lower = value.lower()
+ return any(part in value_lower for part in SENSITIVE_KEY_PARTS)
+
+
+def _decode_escaped_text(value):
+ if not isinstance(value, str) or "\\" not in value:
+ return value
+ if not any(marker in value for marker in ESCAPE_MARKERS):
+ return value
+ return value.encode("utf-8", errors="replace").decode("unicode_escape",
errors="replace")
+
+
def redact_sensitive_data(value):
if isinstance(value, dict):
return {
@@ -61,6 +76,8 @@ def redact_sensitive_data(value):
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
if isinstance(value, str):
+ if not _may_contain_sensitive_key(value):
+ return value
try:
parsed = json.loads(value)
except json.JSONDecodeError:
@@ -129,6 +146,9 @@ class ResponseValidation:
:param path: URL path of the request
:return: Parsed response content or empty dict if none applicable
"""
+ if self._content_type not in RESPONSE_CONTENT_TYPES:
+ raise ValueError(f"Unknown content type: {self._content_type}")
+
result = {}
try:
@@ -142,8 +162,6 @@ class ResponseValidation:
result = response.json()
elif self._content_type == "text":
result = response.text
- else:
- raise ValueError(f"Unknown content type:
{self._content_type}")
except requests.exceptions.HTTPError as e:
if not self._strict and response.status_code == 404:
@@ -170,21 +188,18 @@ class ResponseValidation:
details = response.text or "unknown error"
req_body = redact_sensitive_data(response.request.body) if
response.request.body else "Empty body"
- if isinstance(req_body, str):
- req_body =
req_body.encode("utf-8").decode("unicode_escape")
+ req_body = _decode_escaped_text(req_body)
log.error(
"%s: %s\n[Body]: %s\n[Server Exception]: %s",
method,
- str(e).encode("utf-8").decode("unicode_escape"),
+ _decode_escaped_text(str(e)),
req_body,
details,
)
if response.status_code == 404:
raise NotFoundError(response.content) from e
- if response.status_code >= 400:
- raise ServerError(f"Server Exception: {details}") from e
- raise e
+ raise ServerError(f"Server Exception: {details}") from e
except Exception as e:
log.error("Unhandled exception occurred: %s",
traceback.format_exc())
diff --git a/hugegraph-python-client/src/tests/api/test_auth_routing.py
b/hugegraph-python-client/src/tests/api/test_auth_routing.py
index ebaf6fba..eb2c13a0 100644
--- a/hugegraph-python-client/src/tests/api/test_auth_routing.py
+++ b/hugegraph-python-client/src/tests/api/test_auth_routing.py
@@ -133,7 +133,10 @@ def test_session_debug_log_redacts_sensitive_kwargs():
raw_session.post.return_value = response
session = HGraphSession(cfg, session=raw_session)
- with mock.patch("pyhugegraph.utils.huge_requests.log.debug") as log_debug:
+ with (
+ mock.patch("pyhugegraph.utils.huge_requests.log.isEnabledFor",
return_value=True),
+ mock.patch("pyhugegraph.utils.huge_requests.log.debug") as log_debug,
+ ):
session.request(
"/auth/users",
method="POST",
@@ -143,3 +146,29 @@ def test_session_debug_log_redacts_sensitive_kwargs():
logged_args = str(log_debug.call_args)
assert "super-secret" not in logged_args
assert "***REDACTED***" in logged_args
+
+
+def test_session_skips_debug_redaction_when_debug_disabled():
+ cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None,
gs_supported=False, graph_name="g")
+ cfg.username = "admin"
+ cfg.password = "admin-password"
+ cfg.timeout = 30
+ response = mock.Mock(spec=requests.Response)
+ response.status_code = 200
+ response.json.return_value = {"ok": True}
+ response.raise_for_status.return_value = None
+ raw_session = mock.Mock()
+ raw_session.post.return_value = response
+ session = HGraphSession(cfg, session=raw_session)
+
+ with (
+ mock.patch("pyhugegraph.utils.huge_requests.log.isEnabledFor",
return_value=False),
+ mock.patch("pyhugegraph.utils.huge_requests.redact_sensitive_data") as
redact,
+ ):
+ session.request(
+ "/auth/users",
+ method="POST",
+ data='{"user_name":"marko","user_password":"super-secret"}',
+ )
+
+ redact.assert_not_called()
diff --git a/hugegraph-python-client/src/tests/api/test_gremlin.py
b/hugegraph-python-client/src/tests/api/test_gremlin.py
index 46c297a8..a6934b52 100644
--- a/hugegraph-python-client/src/tests/api/test_gremlin.py
+++ b/hugegraph-python-client/src/tests/api/test_gremlin.py
@@ -167,3 +167,11 @@ def test_gremlin_exec_preserves_auth_exception_type():
with pytest.raises(NotAuthorizedError, match="bad credentials"):
gremlin.exec("g.V()")
+
+
+def test_gremlin_exec_does_not_silently_drop_empty_payload(monkeypatch):
+ gremlin = GremlinManager(_FailingGremlinSession())
+ monkeypatch.setattr(gremlin, "_invoke_request", mock.Mock(return_value={}))
+
+ with pytest.raises(KeyError):
+ gremlin.exec("g.V()")
diff --git a/hugegraph-python-client/src/tests/api/test_response_validation.py
b/hugegraph-python-client/src/tests/api/test_response_validation.py
index f1269a75..9e23f63f 100644
--- a/hugegraph-python-client/src/tests/api/test_response_validation.py
+++ b/hugegraph-python-client/src/tests/api/test_response_validation.py
@@ -20,8 +20,8 @@ from unittest.mock import Mock
import pytest
import requests
-from pyhugegraph.utils.exceptions import NotAuthorizedError,
ResponseParseError, ServerError
-from pyhugegraph.utils.util import ResponseValidation
+from pyhugegraph.utils.exceptions import NotAuthorizedError, NotFoundError,
ResponseParseError, ServerError
+from pyhugegraph.utils.util import ResponseValidation, redact_sensitive_data
pytestmark = pytest.mark.contract
@@ -104,6 +104,37 @@ class TestResponseValidation(unittest.TestCase):
with pytest.raises(ResponseParseError, match="Failed to parse json
response"):
validator(response, "GET", "/graphs/hugegraph")
+ def test_unknown_content_type_raises_value_error_without_wrapping(self):
+ response = Mock(spec=requests.Response)
+ validator = ResponseValidation(content_type="xml")
+
+ with pytest.raises(ValueError, match="Unknown content type: xml"):
+ validator(response, "GET", "/graphs/hugegraph")
+
+ def test_strict_404_preserves_not_found_type(self):
+ response = self._mock_error_response(
+ {"message": "missing vertex"},
+ '{"message":"missing vertex"}',
+ )
+ response.status_code = 404
+ validator = ResponseValidation()
+
+ with pytest.raises(NotFoundError):
+ validator(response, "GET", "/graphs/hugegraph")
+
+ def test_error_log_preserves_plain_utf8_request_body(self):
+ response = self._mock_error_response(
+ {"message": "bad request"},
+ '{"message":"bad request"}',
+ )
+ response.request.body = "中文 payload"
+ validator = ResponseValidation()
+
+ with pytest.raises(ServerError),
unittest.mock.patch("pyhugegraph.utils.util.log.error") as log_error:
+ validator(response, "POST", "/gremlin")
+
+ assert "中文 payload" in str(log_error.call_args)
+
def test_error_log_redacts_sensitive_request_body(self):
response = self._mock_error_response(
{"message": "bad request"},
@@ -119,6 +150,11 @@ class TestResponseValidation(unittest.TestCase):
assert "super-secret" not in logged_args
assert "***REDACTED***" in logged_args
+ def
test_redact_sensitive_data_returns_non_sensitive_string_unchanged(self):
+ plain_body = "large non-sensitive payload with unicode 中文"
+
+ assert redact_sensitive_data(plain_body) == plain_body
+
if __name__ == "__main__":
unittest.main()