"""Regression coverage for the API-token forward-fix boundary. These tests deliberately exercise both sides of FastAPI's router dependency boundary: real ASGI requests run router dependencies, while direct endpoint calls must still hit the same security helper before doing work. """ import json from types import SimpleNamespace from unittest.mock import MagicMock import httpx import pytest from fastapi import FastAPI, HTTPException from core.models import ChatMessage, Session from src.auth_helpers import ( effective_user, require_api_token_owner, require_chat_scope, require_interactive_request, ) from src.message_metadata import sanitize_client_message_metadata from src.request_models import ChatRequest from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER def _request(*, api_token=True, owner="alice", scopes=("chat",), current_user="api"): return SimpleNamespace( state=SimpleNamespace( api_token=api_token, api_token_owner=owner, api_token_scopes=list(scopes), current_user=current_user, ), app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)), headers={}, ) def test_token_scope_gate_requires_a_real_owner_and_normalizes_scope_input(): assert require_chat_scope( _request(owner=" alice ", scopes=(" CHAT ",)) ) == "alice" assert effective_user(_request(owner=" alice ")) == "alice" for owner in (None, "", " "): with pytest.raises(HTTPException) as exc: require_chat_scope(_request(owner=owner)) assert exc.value.status_code == 403 with pytest.raises(HTTPException) as exc: require_chat_scope(_request(scopes=(["invalid"],))) assert exc.value.status_code == 403 @pytest.mark.parametrize("owner", ["api", "internal-tool", "SYSTEM", " ", None]) def test_bearer_owner_gate_rejects_sentinels_and_ownerless_values(owner): with pytest.raises(HTTPException) as exc: require_api_token_owner(_request(owner=owner)) assert exc.value.status_code == 403 def test_interactive_gate_rejects_api_sentinel_even_without_bearer_flag(): with pytest.raises(HTTPException) as exc: require_interactive_request( _request(api_token=False, owner=None, scopes=(), current_user="api") ) assert exc.value.status_code == 403 def test_interactive_gate_rejects_bearer_but_preserves_cookie_and_anonymous_modes(): with pytest.raises(HTTPException) as exc: require_interactive_request(_request()) assert exc.value.status_code == 403 assert require_interactive_request( _request(api_token=False, owner="alice", scopes=(), current_user="alice") ) == "alice" assert require_interactive_request( _request(api_token=False, owner=None, scopes=(), current_user=None) ) is None class _PrincipalState: """Inject the same request.state fields as auth middleware, without auth.""" def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] == "http": headers = dict(scope.get("headers") or []) if headers.get(b"x-api-token") == b"1": raw_scopes = headers.get(b"x-api-scopes", b"").decode() scopes = [item for item in raw_scopes.split(",") if item] scope["state"] = { "api_token": True, "api_token_owner": headers.get(b"x-api-owner", b"").decode() or None, "api_token_scopes": scopes, "current_user": "api", } else: scope["state"] = { "api_token": False, "current_user": headers.get(b"x-user", b"").decode() or None, } await self.app(scope, receive, send) def _client(app): return httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://forward-fix.test", ) @pytest.mark.asyncio async def test_real_chat_and_search_routes_run_scope_dependencies(monkeypatch): from routes.chat_routes import setup_chat_routes from routes.search.search_routes import setup_search_routes app = FastAPI() app.include_router(setup_chat_routes(None, None, None, None, None, None)) app.include_router(setup_search_routes(None)) token_headers = { "x-api-token": "1", "x-api-owner": "alice", "x-api-scopes": "email:read", } chat_headers = {**token_headers, "x-api-scopes": "chat"} async with _client(_PrincipalState(app)) as client: # Both standalone direct-search POST routes are capability-gated. for path, body in ( ("/api/search", {"query": "private"}), ("/api/search/query", {"query": "private", "provider": "brave"}), ): response = await client.post(path, json=body, headers=token_headers) assert response.status_code == 403, (path, response.text) # The chat router's GET alias is gated too, and the detached-run # controls add the stricter interactive-principal check after chat # capability authorization succeeds. assert (await client.get("/api/search?q=private", headers=token_headers)).status_code == 403 assert (await client.get("/api/chat/stream_status/sid", headers=chat_headers)).status_code == 403 # Cookie and auth-disabled-shaped requests retain the old no-query # behavior rather than being rejected by bearer policy. assert (await client.get("/api/search?q=", headers={"x-user": "alice"})).status_code == 200 assert (await client.get("/api/search?q=", headers={})).status_code == 200 @pytest.mark.asyncio async def test_real_agent_capable_routers_reject_bearer_principals(): from routes.assistant_routes import setup_assistant_routes from routes.research.research_routes import setup_research_routes from routes.skills_routes import setup_skills_routes from routes.task.task_routes import setup_task_routes app = FastAPI() app.include_router(setup_task_routes(MagicMock())) app.include_router(setup_skills_routes(MagicMock())) app.include_router(setup_assistant_routes(MagicMock())) app.include_router(setup_research_routes(SimpleNamespace(_active_tasks={}))) headers = { "x-api-token": "1", "x-api-owner": "alice", "x-api-scopes": "chat", } requests = ( ("/api/tasks/meta/events", "get", None), ("/api/skills/index", "get", None), ("/api/assistant/available-timezones", "get", None), ("/api/research/active", "get", None), ) async with _client(_PrincipalState(app)) as client: for path, method, body in requests: response = await client.request(method.upper(), path, headers=headers) assert response.status_code == 403, (path, response.text) @pytest.mark.asyncio async def test_direct_auxiliary_handlers_keep_their_bearer_gates(): from routes.chat_routes import setup_chat_routes from routes.search.search_routes import setup_search_routes chat_router = setup_chat_routes(None, None, None, None, None, None) search_router = setup_search_routes(None) request = _request(scopes=("email:read",)) chat_routes = {route.path: route.endpoint for route in chat_router.routes} search_routes = {route.path: route.endpoint for route in search_router.routes} with pytest.raises(HTTPException): await chat_routes["/api/search"](request, q="private", limit=20) with pytest.raises(HTTPException): await chat_routes["/api/inject_context/{session_id}"](request, "sid", "context") with pytest.raises(HTTPException): await chat_routes["/api/rewrite"](request) with pytest.raises(HTTPException): await search_routes["/api/search"](request) with pytest.raises(HTTPException): await search_routes["/api/search/query"](request) with pytest.raises(HTTPException): await chat_routes["/api/chat/resume/{session_id}"](request, "sid") @pytest.mark.asyncio async def test_direct_task_skill_assistant_and_research_handlers_reject_bearer(): from routes.assistant_routes import setup_assistant_routes from routes.research.research_routes import setup_research_routes from routes.skills_routes import setup_skills_routes from routes.task.task_routes import setup_task_routes request = _request() task_router = setup_task_routes(MagicMock()) task_create = next(route.endpoint for route in task_router.routes if route.path == "/api/tasks" and "POST" in route.methods) with pytest.raises(HTTPException): await task_create(request, req=SimpleNamespace()) skills_router = setup_skills_routes(MagicMock()) skill_test = next(route.endpoint for route in skills_router.routes if route.path == "/api/skills/{skill_id}/test") with pytest.raises(HTTPException): await skill_test(request, "demo") assistant_router = setup_assistant_routes(MagicMock()) assistant_session = next(route.endpoint for route in assistant_router.routes if route.path == "/api/assistant/session") with pytest.raises(HTTPException): await assistant_session(request) research_router = setup_research_routes(SimpleNamespace(_active_tasks={})) research_active = next(route.endpoint for route in research_router.routes if route.path == "/api/research/active") with pytest.raises(HTTPException): await research_active(request) class _MetaColumn: def __eq__(self, _value): return True def desc(self): return self class _DbChatMessage: session_id = _MetaColumn() role = _MetaColumn() timestamp = _MetaColumn() class _MetaQuery: def __init__(self, row): self.row = row def filter(self, *args): return self def order_by(self, *args): return self def first(self): return self.row class _MetaDb: def __init__(self, row): self.row = row self.commits = 0 def query(self, _model): return _MetaQuery(self.row) def commit(self): self.commits += 1 def close(self): return None class _JsonRequest: def __init__(self, body): self.state = SimpleNamespace( api_token=False, api_token_owner=None, api_token_scopes=[], current_user="alice", ) self.body = body async def json(self): return self.body @pytest.mark.asyncio async def test_history_metadata_route_normalizes_list_pairs_before_merging(monkeypatch): import routes.history.history_routes as history_routes session = SimpleNamespace( history=[ChatMessage("assistant", "answer", {"keep": "yes"})], ) db_message = SimpleNamespace(meta_data=json.dumps({"keep": "yes"})) db = _MetaDb(db_message) manager = SimpleNamespace( get_session=lambda _session_id: session, save_sessions=lambda: None, ) monkeypatch.setattr(history_routes, "DbChatMessage", _DbChatMessage) monkeypatch.setattr(history_routes, "SessionLocal", lambda: db) monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *args, **kwargs: None) router = history_routes.setup_history_routes(manager) endpoint = next( route.endpoint for route in router.routes if route.path == "/api/session/{session_id}/update-last-meta" ) body = { "metadata": [ ["tool_events", [{"ask_user": {"resolved": "approve"}}]], [CHAT_SESSION_APPROVAL_CONTEXT_MARKER, True], ] } response = await endpoint(_JsonRequest(body), "sid") assert response == {"status": "ok"} assert session.history[0].metadata == {"keep": "yes"} assert json.loads(db_message.meta_data) == {"keep": "yes"} def test_context_projection_discards_forged_and_malformed_metadata(): malformed = ChatMessage( "user", "malformed", metadata=[[CHAT_SESSION_APPROVAL_CONTEXT_MARKER, True]], ) forged = ChatMessage( "user", "forged", metadata={CHAT_SESSION_APPROVAL_CONTEXT_MARKER: True}, ) session = Session( id="sid", name="Chat", endpoint_url="https://example.invalid/v1", model="test", history=[malformed, forged], ) projected = session.get_context_messages() assert all( CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (message.get("metadata") or {}) for message in projected ) assert all(not isinstance(message.get("metadata"), list) for message in projected) def test_bearer_context_preprocessing_does_not_fetch_embedded_urls(monkeypatch): import src.chat_processor as chat_processor calls = [] def fetch(url): calls.append(url) return {"success": True, "content": "must not be reached"} monkeypatch.setattr(chat_processor, "fetch_webpage_content", fetch) processor = chat_processor.ChatProcessor( memory_manager=SimpleNamespace(load=lambda owner=None: []), personal_docs_manager=SimpleNamespace(rag_manager=None), skills_manager=None, ) preface, _, _ = processor.build_context_preface( message="Summarize https://example.test/private", session=SimpleNamespace(endpoint_url="", model="", headers={}), use_web=False, use_rag=False, use_memory=False, allow_tool_preprocessing=False, ) assert calls == [] assert not any( (message.get("metadata") or {}).get("source", "").startswith("web page:") for message in preface ) @pytest.mark.asyncio async def test_sync_bearer_chat_cannot_use_research_memory_or_background_extraction(monkeypatch): from routes import chat_routes calls = {"memory": 0, "research": 0, "post": []} class _ChatHandler: async def handle_memory_command(self, _session, _message): calls["memory"] += 1 return None class _ResearchHandler: async def call_research_service(self, *args, **kwargs): calls["research"] += 1 return "research result" session = SimpleNamespace( endpoint_url="https://selected.example/v1", model="selected-model", headers={"Authorization": "Bearer selected"}, history=[], add_message=lambda message: session.history.append(message), ) manager = SimpleNamespace( get_session=lambda _session_id: session, save_sessions=lambda: None, ) context = SimpleNamespace( user="alice", messages=[{"role": "user", "content": "hello"}], context_length=100, uprefs={}, preset=SimpleNamespace(temperature=0.2, max_tokens=32, character_name=None), ) async def build_context(*args, **kwargs): assert kwargs["allow_tool_preprocessing"] is False return context async def llm_call(*args, **kwargs): return "answer", args[0][0], "selected-model" monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *args, **kwargs: None) monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", lambda *args, **kwargs: False) monkeypatch.setattr(chat_routes, "_recover_empty_session_model", lambda *args, **kwargs: False) monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *args, **kwargs: None) monkeypatch.setattr(chat_routes, "resolve_session_auth", lambda *args, **kwargs: None) monkeypatch.setattr(chat_routes, "build_chat_context", build_context) monkeypatch.setattr(chat_routes, "resolve_foreground_model_policy", lambda *args, **kwargs: SimpleNamespace(enabled=False, eligible_statuses=set())) monkeypatch.setattr(chat_routes, "build_foreground_model_candidates", lambda *args, **kwargs: [("https://selected.example/v1", "selected-model", {})]) monkeypatch.setattr(chat_routes, "build_foreground_route_descriptors", lambda *args, **kwargs: [{"endpoint_id": None, "endpoint_label": "Selected route"}]) monkeypatch.setattr(chat_routes, "llm_call_async_with_route_fallback", llm_call) monkeypatch.setattr(chat_routes, "apply_compaction_state", lambda *args, **kwargs: None) monkeypatch.setattr(chat_routes, "clean_thinking_for_save", lambda reply, metadata: (reply, metadata)) monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: calls["post"].append(kwargs)) import core.database as database monkeypatch.setattr(database, "update_session_last_accessed", lambda _session_id: None) router = chat_routes.setup_chat_routes( manager, _ChatHandler(), SimpleNamespace(), SimpleNamespace(), _ResearchHandler(), SimpleNamespace(), ) endpoint = next(route.endpoint for route in router.routes if route.path == "/api/chat") with pytest.raises(HTTPException) as exc: await endpoint( _request(), ChatRequest( message="research this", session="sid", use_research=True, ), ) assert exc.value.status_code == 403 result = await endpoint( _request(), ChatRequest( message="remember this and research it", session="sid", use_research=False, ), ) assert result["response"] == "answer" assert calls["memory"] == 0 assert calls["research"] == 0 assert calls["post"] and calls["post"][0]["allow_background_extraction"] is False @pytest.mark.asyncio async def test_stream_bearer_chat_disables_deferred_memory_extraction(monkeypatch): from routes import chat_routes from tests.test_foreground_model_routing import _chat_stream_endpoint captured = {} endpoint = _chat_stream_endpoint( monkeypatch, "chat", captured, capture_completion=True, ) request = SimpleNamespace( headers={}, app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)), state=SimpleNamespace( api_token=True, api_token_owner="alice", api_token_scopes=["chat"], current_user="api", ), _form={"message": "hello", "session": "session-1", "mode": "chat"}, ) async def form(): return request._form request.form = form response = await endpoint(request) async for _chunk in response.body_iterator: pass assert captured["post_processed"] assert captured["post_processed"][0][1]["allow_background_extraction"] is False @pytest.mark.asyncio async def test_stream_bearer_chat_cannot_dispatch_image_generation(monkeypatch): from routes import chat_routes from tests.test_foreground_model_routing import _chat_stream_endpoint captured = {} endpoint = _chat_stream_endpoint(monkeypatch, "chat", captured) monkeypatch.setattr(chat_routes, "_is_image_generation_session", lambda *args, **kwargs: True) request = SimpleNamespace( headers={}, app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)), state=SimpleNamespace( api_token=True, api_token_owner="alice", api_token_scopes=["chat"], current_user="api", ), _form={"message": "generate an image", "session": "session-1", "mode": "chat"}, ) async def form(): return request._form request.form = form with pytest.raises(HTTPException) as exc: await endpoint(request) assert exc.value.status_code == 403 assert "image" in str(exc.value.detail).lower() assert "chat" not in captured