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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user