mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 02:42:22 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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"}}
|
||||
|
||||
|
||||
# ── Callback origin ───────────────────────────────────────────────
|
||||
#
|
||||
# The redirect URI is registered with the authorization server (dynamically for
|
||||
# remote MCP servers, by hand for Google ones) and the browser is sent to it
|
||||
# after authorizing. It is resolved once, outside any request, so it cannot be
|
||||
# derived from the request the way the email OAuth routes derive theirs — an
|
||||
# operator-supplied origin is the only thing that can be right behind a proxy.
|
||||
# What the default can get right is the port, which the app knows.
|
||||
|
||||
_REDIRECT_ENV = ("OAUTH_REDIRECT_BASE_URL", "APP_PUBLIC_URL", "APP_PORT")
|
||||
|
||||
|
||||
def _resolve_base(monkeypatch, **env):
|
||||
for key in _REDIRECT_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, value in env.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
return mcp_oauth._resolve_redirect_base()
|
||||
|
||||
|
||||
def test_redirect_base_defaults_to_the_bound_port(monkeypatch):
|
||||
# The macOS launcher serves on 7860 because AirPlay Receiver holds 7000; a
|
||||
# callback pinned to 7000 lands on AirPlay instead of Odysseus.
|
||||
assert _resolve_base(monkeypatch, APP_PORT="7860") == "http://localhost:7860"
|
||||
|
||||
|
||||
def test_redirect_base_keeps_7000_when_app_port_is_unset(monkeypatch):
|
||||
assert _resolve_base(monkeypatch) == "http://localhost:7000"
|
||||
|
||||
|
||||
def test_redirect_base_prefers_the_explicit_origin(monkeypatch):
|
||||
# Only an operator-supplied origin can be right behind a TLS proxy, so it
|
||||
# outranks the derived default — and its trailing slash is trimmed.
|
||||
resolved = _resolve_base(
|
||||
monkeypatch, OAUTH_REDIRECT_BASE_URL="https://odysseus.example/", APP_PORT="7860"
|
||||
)
|
||||
assert resolved == "https://odysseus.example"
|
||||
|
||||
|
||||
def test_redirect_base_accepts_app_public_url_as_the_alias(monkeypatch):
|
||||
assert (
|
||||
_resolve_base(monkeypatch, APP_PUBLIC_URL="https://public.example", APP_PORT="7860")
|
||||
== "https://public.example"
|
||||
)
|
||||
|
||||
|
||||
# ── Paste-back form origin ────────────────────────────────────────
|
||||
|
||||
def _authorize_page():
|
||||
from routes.mcp.mcp_routes import _oauth_authorize_page
|
||||
|
||||
return _oauth_authorize_page(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?state=srv-1",
|
||||
"srv-1",
|
||||
"https://odysseus.example.com/api/mcp/oauth/callback",
|
||||
)
|
||||
|
||||
|
||||
def test_paste_back_form_action_is_relative():
|
||||
# Remote users finish the flow by pasting the callback URL into this form,
|
||||
# so it has to post back to the origin they are on. An absolute action
|
||||
# cannot: an http:// one is mixed content on an HTTPS page and gets blocked,
|
||||
# and the app cannot reliably tell that it is behind TLS, because uvicorn
|
||||
# only honours X-Forwarded-Proto from a peer inside --forwarded-allow-ips
|
||||
# (default 127.0.0.1, which a proxy on the Docker bridge is not). A relative
|
||||
# action is resolved by the browser and is right in every one of those cases.
|
||||
page = _authorize_page()
|
||||
assert 'action="/api/mcp/oauth/exchange/srv-1"' in page
|
||||
assert 'action="http' not in page
|
||||
|
||||
|
||||
# ── Docker configurability ────────────────────────────────────────
|
||||
#
|
||||
# The override above is the only fix available to a Docker install: the
|
||||
# container always listens on 7000 and cannot see the host port map, so the
|
||||
# derived default cannot be right there. Compose has to forward the variable
|
||||
# or the escape hatch does not exist.
|
||||
|
||||
_COMPOSE_FILES = (
|
||||
"docker-compose.yml",
|
||||
"docker-compose.gpu-nvidia.yml",
|
||||
"docker-compose.gpu-amd.yml",
|
||||
)
|
||||
|
||||
|
||||
def _repo_root():
|
||||
from pathlib import Path
|
||||
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_redirect_base_override_is_forwarded_into_the_container():
|
||||
import yaml
|
||||
|
||||
for name in _COMPOSE_FILES:
|
||||
path = _repo_root() / name
|
||||
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
environment = set(compose["services"]["odysseus"]["environment"])
|
||||
assert "OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}" in environment, name
|
||||
|
||||
|
||||
def test_redirect_base_override_is_documented():
|
||||
import pytest
|
||||
|
||||
env_example = _repo_root() / ".env.example"
|
||||
if not env_example.exists():
|
||||
pytest.skip("this checkout does not include the optional .env.example file")
|
||||
assert "# OAUTH_REDIRECT_BASE_URL=" in env_example.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── Launcher port propagation ─────────────────────────────────────
|
||||
#
|
||||
# The derived default is only as good as APP_PORT, and every launcher hands the
|
||||
# port to uvicorn as a command-line flag, which the app cannot read back. Each
|
||||
# one has to put the same value in the environment or the callback falls back to
|
||||
# 7000 — which is the macOS-launcher-on-7860 case this whole change is about.
|
||||
# internal_api_base() and companion pairing read APP_PORT too, so they go wrong
|
||||
# in the same way.
|
||||
|
||||
_LAUNCHERS = (
|
||||
# file, the export, the uvicorn flag it has to agree with
|
||||
("start-macos.sh", 'export APP_PORT="$PORT"', '--port "$PORT"'),
|
||||
("build-macos-app.sh", 'export APP_PORT="$PORT"', '--port "$PORT"'),
|
||||
("launch-windows.ps1", "$env:APP_PORT = $Port", "--port $Port"),
|
||||
)
|
||||
|
||||
|
||||
def test_launchers_export_the_port_they_serve_on():
|
||||
for name, export, uvicorn_flag in _LAUNCHERS:
|
||||
text = (_repo_root() / name).read_text(encoding="utf-8")
|
||||
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
|
||||
assert export in text, f"{name}: serves on a port the app cannot read back"
|
||||
Reference in New Issue
Block a user