Files
odysseus/tests/test_mcp_oauth.py
T
jagadish-zentiti bc771fbc1e fix(mcp): guard DbTokenStorage against non-dict oauth_tokens JSON (#5107)
_load() returned whatever json.loads() produced without checking it was a
dict; _update() did the same before assigning data[key] = value. If the
oauth_tokens column ever held a JSON array or primitive (DB corruption,
manual edit, migration drift), _load()'s callers crashed with
AttributeError on .get(), and _update() crashed with TypeError trying to
item-assign into a list/string/int.

Validate the parsed value is a dict in both methods, falling back to {}
otherwise - same recovery behavior already used elsewhere in the codebase
for this exact JSON-blob-is-not-a-dict shape (_parse_tool_args,
_is_sensitive_path's siblings).

Adds 3 regression tests for _load, get_tokens, and _update against a
non-dict oauth_tokens value.

Fixes #5082
2026-07-11 05:26:23 +01:00

133 lines
3.6 KiB
Python

import asyncio
import json
from src import mcp_oauth
def test_registry_resolve_returns_code_and_state():
async def go():
fut = mcp_oauth.register_pending("st-1")
assert mcp_oauth.resolve_pending("st-1", "the-code") is True
return await asyncio.wait_for(fut, timeout=1)
code, state = asyncio.run(go())
assert code == "the-code"
assert state == "st-1"
def test_resolve_unknown_state_is_false():
assert mcp_oauth.resolve_pending("nope", "x") is False
def test_register_pending_prunes_abandoned_flows():
import time as _t
async def go():
mcp_oauth._pending.clear()
mcp_oauth._pending_ts.clear()
old = mcp_oauth.register_pending("old-state")
# Backdate the entry past the authorization window.
mcp_oauth._pending_ts["old-state"] = _t.monotonic() - (mcp_oauth.AUTH_WAIT_SECONDS + 1)
# A new registration triggers a prune of the stale one.
mcp_oauth.register_pending("new-state")
return old
old = asyncio.run(go())
assert "old-state" not in mcp_oauth._pending
assert "old-state" not in mcp_oauth._pending_ts
assert "new-state" in mcp_oauth._pending
assert old.cancelled()
def test_build_provider_has_odysseus_client_metadata():
p = mcp_oauth.build_provider("srv-1", "https://example.com/mcp")
md = p.context.client_metadata
assert md.client_name == "Odysseus"
assert "authorization_code" in md.grant_types
assert "refresh_token" in md.grant_types
assert str(md.redirect_uris[0]).rstrip("/") == mcp_oauth.REDIRECT_URI.rstrip("/")
def test_db_token_storage_round_trip():
from mcp.shared.auth import OAuthToken
class FakeSrv:
oauth_tokens = None
srv = FakeSrv()
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return srv
class FakeSession:
def query(self, *a):
return FakeQuery()
def commit(self):
pass
def close(self):
pass
storage = mcp_oauth.DbTokenStorage("srv-1", session_factory=lambda: FakeSession())
async def go():
await storage.set_tokens(OAuthToken(access_token="abc", token_type="Bearer"))
return await storage.get_tokens()
t = asyncio.run(go())
assert t.access_token == "abc"
assert srv.oauth_tokens is not None # persisted as JSON
def _fake_storage(oauth_tokens):
class FakeSrv:
pass
srv = FakeSrv()
srv.oauth_tokens = oauth_tokens
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return srv
class FakeSession:
def query(self, *a):
return FakeQuery()
def commit(self):
pass
def close(self):
pass
return srv, mcp_oauth.DbTokenStorage("srv-1", session_factory=lambda: FakeSession())
def test_load_falls_back_to_empty_dict_for_non_dict_json():
# A corrupted/migrated oauth_tokens column holding a JSON array, not an
# object, must not crash _load()'s callers with AttributeError.
_srv, storage = _fake_storage('["stale", "data"]')
assert storage._load() == {}
def test_get_tokens_returns_none_for_non_dict_oauth_tokens():
_srv, storage = _fake_storage("42")
async def go():
return await storage.get_tokens()
assert asyncio.run(go()) is None
def test_update_recovers_from_non_dict_oauth_tokens():
# _update() must not raise TypeError trying to item-assign into a list.
srv, storage = _fake_storage('["stale", "data"]')
storage._update("tokens", {"access_token": "new"})
assert json.loads(srv.oauth_tokens) == {"tokens": {"access_token": "new"}}