SchedulerClient.register_volatile_fetch (consolidation's prefetch
routing) registered tasks that were dead on arrival:
- JSON body stored under 'body', which rest_api_executor ignores
(it only reads config['payload'])
- no auth block, so the scheduled POST to /volatile/fetch would 401
against library-desk's verify_api_key
- user placed in the body while /volatile/fetch endpoints require it
as a query parameter (RequiredUserQuery) - would 422 regardless
The task config now carries user in the URL query string (encoded),
an empty payload, and auth {type: bearer, token: ${LIBRARY_API_KEY}}
substituted Scheduler-side (never stored raw).
SchedulerClient also sent no Authorization to the Scheduler API itself,
so registration 401'd silently at consolidation time; it now sends
Bearer auth from the new scheduler_api_key setting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
"""
|
|
Offline unit tests for SchedulerClient (Phase D review batch).
|
|
|
|
Pins the runtime prefetch-registration fixes:
|
|
- SchedulerClient sends Authorization: Bearer <SCHEDULER_API_KEY> on its
|
|
own calls (the Scheduler's /tasks endpoints are auth-guarded; a bare
|
|
client 401s and registration fails silently at consolidation time)
|
|
- register_volatile_fetch task config is actually executable by the
|
|
Scheduler's rest_api_executor:
|
|
* JSON body under config["payload"] (a "body" key is silently ignored)
|
|
* user as a QUERY parameter (the /volatile/fetch endpoints use
|
|
RequiredUserQuery; a body user would 422)
|
|
* an auth block with the ${LIBRARY_API_KEY} placeholder so the
|
|
scheduled POST passes library-desk's verify_api_key
|
|
"""
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src.clients.scheduler_client import (
|
|
LIBRARY_API_KEY_PLACEHOLDER,
|
|
SchedulerClient,
|
|
)
|
|
|
|
|
|
def _mock_transport(seen):
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen.append(request)
|
|
path = request.url.path
|
|
if request.method == "GET" and path.startswith("/tasks/"):
|
|
return httpx.Response(404) # task does not exist yet
|
|
if request.method == "POST" and path == "/tasks":
|
|
return httpx.Response(200, json={"ok": True})
|
|
return httpx.Response(200, json={})
|
|
|
|
return httpx.MockTransport(handler)
|
|
|
|
|
|
@pytest.fixture
|
|
def seen_requests():
|
|
return []
|
|
|
|
|
|
@pytest.fixture
|
|
def client(seen_requests, monkeypatch):
|
|
"""SchedulerClient whose real _get_client builds against a MockTransport."""
|
|
import src.clients.scheduler_client as mod
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def client_factory(**kwargs):
|
|
kwargs["transport"] = _mock_transport(seen_requests)
|
|
return real_async_client(**kwargs)
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", client_factory)
|
|
return SchedulerClient(base_url="http://scheduler.test:8090", api_key="sched-key")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSchedulerApiAuth:
|
|
@pytest.mark.asyncio
|
|
async def test_bearer_auth_sent_on_all_calls(self, client, seen_requests):
|
|
ok = await client.register_volatile_fetch(
|
|
namespace="weather",
|
|
key="rotterdam",
|
|
user="llm_tester",
|
|
schedule={"minute": 0},
|
|
)
|
|
|
|
assert ok is True
|
|
assert seen_requests, "no HTTP calls were made"
|
|
for request in seen_requests:
|
|
assert request.headers.get("Authorization") == "Bearer sched-key"
|
|
|
|
def test_missing_api_key_is_flagged(self, caplog):
|
|
with caplog.at_level("WARNING"):
|
|
SchedulerClient(base_url="http://scheduler.test:8090")
|
|
assert any("without an API key" in r.message for r in caplog.records)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRegisterVolatileFetchConfig:
|
|
@pytest.mark.asyncio
|
|
async def _registered_config(self, client, seen_requests, user="llm_tester"):
|
|
ok = await client.register_volatile_fetch(
|
|
namespace="weather",
|
|
key="rotterdam",
|
|
user=user,
|
|
schedule={"minute": 0, "hour": -1},
|
|
)
|
|
assert ok is True
|
|
create = next(
|
|
r for r in seen_requests
|
|
if r.method == "POST" and r.url.path == "/tasks"
|
|
)
|
|
import json
|
|
return json.loads(create.content)["config"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_body_key_not_used_payload_is(self, client, seen_requests):
|
|
config = await self._registered_config(client, seen_requests)
|
|
# rest_api_executor only reads config["payload"]; "body" is ignored
|
|
assert "body" not in config
|
|
assert config["payload"] == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_is_query_parameter(self, client, seen_requests):
|
|
config = await self._registered_config(client, seen_requests)
|
|
assert config["url"] == (
|
|
"http://library-desk:8089/volatile/fetch/weather/rotterdam"
|
|
"?user=llm_tester"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_block_uses_placeholder(self, client, seen_requests):
|
|
config = await self._registered_config(client, seen_requests)
|
|
assert config["auth"] == {
|
|
"type": "bearer",
|
|
"token": LIBRARY_API_KEY_PLACEHOLDER,
|
|
}
|
|
# never the raw key, never in plain headers (not substituted there)
|
|
assert "Authorization" not in config.get("headers", {})
|