"""Tests for MCP auth: bearer-token validation that reuses api_keys.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from scribe.mcp.auth import resolve_bearer, resolve_bearer_to_user_id @pytest.mark.asyncio async def test_resolve_bearer_missing_header_returns_none(): assert await resolve_bearer_to_user_id(None) is None @pytest.mark.asyncio async def test_resolve_bearer_malformed_header_returns_none(): assert await resolve_bearer_to_user_id("Token abc") is None assert await resolve_bearer_to_user_id("Bearer") is None assert await resolve_bearer_to_user_id("Bearer ") is None assert await resolve_bearer_to_user_id("") is None @pytest.mark.asyncio async def test_resolve_bearer_unknown_token_returns_none(): with patch( "scribe.mcp.auth.lookup_key", AsyncMock(return_value=None), ): assert await resolve_bearer_to_user_id("Bearer fmcp_doesnotexist") is None @pytest.mark.asyncio async def test_resolve_bearer_valid_token_returns_user_id(): fake_key = MagicMock() fake_key.user_id = 42 with patch( "scribe.mcp.auth.lookup_key", AsyncMock(return_value=fake_key), ): uid = await resolve_bearer_to_user_id("Bearer fmcp_validkey") assert uid == 42 @pytest.mark.asyncio async def test_resolve_bearer_calls_lookup_with_stripped_token(): """The Bearer prefix and any trailing whitespace must be stripped before lookup.""" fake_key = MagicMock() fake_key.user_id = 1 mock_lookup = AsyncMock(return_value=fake_key) with patch("scribe.mcp.auth.lookup_key", mock_lookup): await resolve_bearer_to_user_id("Bearer fmcp_abc123 ") mock_lookup.assert_awaited_once_with("fmcp_abc123") # ── resolve_bearer (user_id + scope) ──────────────────────────────────── @pytest.mark.asyncio async def test_resolve_bearer_returns_user_id_and_scope(): fake_key = MagicMock() fake_key.user_id = 9 fake_key.scope = "read" with patch("scribe.mcp.auth.lookup_key", AsyncMock(return_value=fake_key)): assert await resolve_bearer("Bearer fmcp_x") == (9, "read") @pytest.mark.asyncio async def test_resolve_bearer_none_for_invalid(): with patch("scribe.mcp.auth.lookup_key", AsyncMock(return_value=None)): assert await resolve_bearer("Bearer nope") is None assert await resolve_bearer(None) is None # ── read-only scope gate ──────────────────────────────────────────────── def test_body_calls_write_tool_classifies_correctly(): import json from scribe.mcp.server import _body_calls_write_tool def call(name): return json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": {}}}).encode() # Write-class tools are gated. assert _body_calls_write_tool(call("create_note")) is True assert _body_calls_write_tool(call("delete_project")) is True assert _body_calls_write_tool(call("purge_trash")) is True # An unknown/new tool defaults to write (default-deny for read keys). assert _body_calls_write_tool(call("brand_new_tool")) is True # Read tools and non-call methods pass. assert _body_calls_write_tool(call("list_notes")) is False assert _body_calls_write_tool(call("get_recent")) is False assert _body_calls_write_tool( json.dumps({"method": "tools/list"}).encode() ) is False assert _body_calls_write_tool(b"not json") is False