fix: WS5 hazards batch - CORS, scheduler auth, reranker dedupe, wiring, write txns

- CORS: drop allow_credentials (wildcard origin + credentials told
  browsers to attach credentials for any site); origins configurable via
  CORS_ALLOW_ORIGINS (default * is safe without credentials). Verified
  live: preflight no longer advertises access-control-allow-credentials.
- Scheduler tasks: auth moved from a plain Authorization header (which
  the Scheduler's rest_api_executor does NOT env-substitute) to its
  auth {type: bearer, token: ${LIBRARY_API_KEY}} block, substituted from
  the Scheduler's own environment at execution time. The registrar no
  longer resolves the real key client-side, so it can never be persisted
  into the scheduled_tasks.config JSONB column. Also fixed: JSON bodies
  moved from the ignored "body" key to "payload" (the executor only
  reads config["payload"], so the tasks would have POSTed empty bodies
  and failed required-user validation).
- Reranker: parsed ranking indices are deduplicated preserving first
  occurrence (an LLM answer like "3,3,1" duplicated a result).
- HybridRAG wiring consolidated into dependencies.get_hybrid_rag_service
  (now including volatile_service); the inline copies in /query/hybrid
  and /wiki/pages/smart-create are gone - smart-create previously ran
  without the volatile leg, and the singleton was unused.
- Remaining Neo4j writes (GraphService ingestion/deletes/purges/entity
  mentions, webhook rename+delete cleanup, document-sync _index_graph,
  consolidation mark-processed/add-entity) moved from auto-commit
  execute_query to execute_write managed transactions with retry.

Verified end-to-end on the local dev server as llm_tester: /query/hybrid
200 with all five legs ok (volatile now active), background persistence
landed as one transaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
This commit is contained in:
2026-07-14 14:50:03 +02:00
co-authored by Claude Fable 5
parent 69e6a01e65
commit 9ceec1464a
19 changed files with 159 additions and 144 deletions
+3 -2
View File
@@ -47,6 +47,7 @@ def mock_neo4j():
"""Mock Neo4j client."""
mock = AsyncMock()
mock.execute_query = AsyncMock()
mock.execute_write = AsyncMock()
return mock
@@ -520,8 +521,8 @@ async def test_mark_search_processed(consolidation_service, mock_neo4j):
"""Test marking search as processed."""
await consolidation_service._mark_search_processed(TEST_SEARCH_ID)
mock_neo4j.execute_query.assert_called_once()
call_args = mock_neo4j.execute_query.call_args
mock_neo4j.execute_write.assert_called_once()
call_args = mock_neo4j.execute_write.call_args
assert TEST_SEARCH_ID in str(call_args)
+1
View File
@@ -67,6 +67,7 @@ def _make_service(searches, ollama_response):
return []
neo4j.execute_query = AsyncMock(side_effect=fake_query)
neo4j.execute_write = AsyncMock(side_effect=fake_query)
ollama = AsyncMock()
ollama.generate_text = AsyncMock(return_value=ollama_response)
+1
View File
@@ -42,6 +42,7 @@ def mock_ollama():
def mock_neo4j():
neo4j = MagicMock()
neo4j.execute_query = AsyncMock(return_value=[])
neo4j.execute_write = AsyncMock(return_value=[])
return neo4j
+8 -5
View File
@@ -27,6 +27,7 @@ def mock_neo4j():
"""Mock Neo4j client."""
mock = AsyncMock()
mock.execute_query = AsyncMock(return_value=[{"d": {"page_id": TEST_PAGE_ID}}])
mock.execute_write = AsyncMock(return_value=[{"d": {"page_id": TEST_PAGE_ID}}])
return mock
@@ -89,12 +90,12 @@ class TestDocumentNodeCreation:
user=TEST_USER
)
# Verify execute_query was called
assert mock_neo4j.execute_query.called
# Verify the write transaction was used
assert mock_neo4j.execute_write.called
assert result.success is True
# Find the document creation query
calls = mock_neo4j.execute_query.call_args_list
calls = mock_neo4j.execute_write.call_args_list
doc_creation_call = None
for call in calls:
query = call[0][0] if call[0] else ""
@@ -128,7 +129,7 @@ class TestDocumentNodeCreation:
assert result.success is True
# Find the document creation query
calls = mock_neo4j.execute_query.call_args_list
calls = mock_neo4j.execute_write.call_args_list
doc_creation_call = None
for call in calls:
query = call[0][0] if call[0] else ""
@@ -171,6 +172,7 @@ class TestEntityStubSkipping:
assert result.success is True
# Neo4j should NOT be called for entity-stub pages
assert mock_neo4j.execute_query.call_count == 0
assert mock_neo4j.execute_write.call_count == 0
@pytest.mark.asyncio
async def test_skip_auto_generated_pages(
@@ -196,6 +198,7 @@ class TestEntityStubSkipping:
assert result.success is True
assert mock_neo4j.execute_query.call_count == 0
assert mock_neo4j.execute_write.call_count == 0
class TestPageNotFound:
@@ -242,7 +245,7 @@ class TestEntityExtraction:
assert result.success is True
# Should have called neo4j at least once (for document node)
assert mock_neo4j.execute_query.called
assert mock_neo4j.execute_write.called
if __name__ == "__main__":
+20 -11
View File
@@ -83,11 +83,20 @@ class TestSchedulerTaskDefinitions:
mod = self._load_module()
for task in mod.TASKS:
config = task["config"]
auth = config["headers"]["Authorization"]
assert auth == f"Bearer {mod.API_KEY_PLACEHOLDER}"
# Explicit production tenant in body or query string (Phase B)
body_user = config.get("body", {}).get("user")
assert body_user == "jpmschweitzer" or "user=jpmschweitzer" in config["url"]
# Auth goes through the executor's auth block so the Scheduler
# substitutes ${LIBRARY_API_KEY} from ITS environment at
# execution time (plain headers are NOT substituted).
assert config["auth"] == {
"type": "bearer",
"token": mod.API_KEY_PLACEHOLDER,
}
assert "Authorization" not in config.get("headers", {})
# The executor sends config["payload"] as the JSON body ("body"
# would be silently ignored)
assert "body" not in config
# Explicit production tenant in payload or query string (Phase B)
payload_user = config.get("payload", {}).get("user")
assert payload_user == "jpmschweitzer" or "user=jpmschweitzer" in config["url"]
def test_paperless_task_hits_existing_endpoint(self):
mod = self._load_module()
@@ -96,13 +105,13 @@ class TestSchedulerTaskDefinitions:
assert "/maintenance/cleanup/paperless" in task["config"]["url"]
assert "dry_run=false" in task["config"]["url"]
def test_substitute_api_key_replaces_placeholder_without_mutating(self):
def test_no_client_side_key_substitution(self):
"""The raw API key must never be resolved client-side — that would
store it hardcoded in the Scheduler's scheduled_tasks.config."""
mod = self._load_module()
original = mod.TASKS[0]
resolved = mod.substitute_api_key(original, "sekret")
assert resolved["config"]["headers"]["Authorization"] == "Bearer sekret"
# The module-level definition keeps the placeholder
assert mod.API_KEY_PLACEHOLDER in original["config"]["headers"]["Authorization"]
assert not hasattr(mod, "substitute_api_key")
for task in mod.TASKS:
assert mod.API_KEY_PLACEHOLDER in task["config"]["auth"]["token"]
def test_dry_run_is_default_and_sends_nothing(self, capsys, monkeypatch):
mod = self._load_module()
+4 -2
View File
@@ -110,9 +110,11 @@ def hybrid_service(vector_service, graph_service, volatile_service, mock_ollama)
def _all_cypher(mock_neo4j) -> str:
"""Concatenate all Cypher sent to the mocked Neo4j client."""
"""Concatenate all Cypher sent to the mocked Neo4j client (reads + writes)."""
return "\n".join(
str(call.args[0]) for call in mock_neo4j.execute_query.await_args_list
str(call.args[0])
for mock in (mock_neo4j.execute_query, mock_neo4j.execute_write)
for call in mock.await_args_list
)