232 lines
8.5 KiB
Python
232 lines
8.5 KiB
Python
"""Tests for the mcp-oauth-remote-gateway optional skill.
|
|
|
|
Covers the diagnose-oauth-mcp.py decision tree (TOKEN_OK / REFRESH_FIXED /
|
|
SESSION_REVOKED / REFRESH_DEAD), the HERMES_HOME resolution fallback, the
|
|
atomic --write persistence path, and SKILL.md frontmatter invariants.
|
|
No live network calls — urllib is mocked throughout.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
SKILL_DIR = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "optional-skills"
|
|
/ "mcp"
|
|
/ "mcp-oauth-remote-gateway"
|
|
)
|
|
SCRIPT_PATH = SKILL_DIR / "scripts" / "diagnose-oauth-mcp.py"
|
|
SKILL_MD = SKILL_DIR / "SKILL.md"
|
|
|
|
|
|
def load_module():
|
|
spec = importlib.util.spec_from_file_location("diagnose_oauth_mcp", SCRIPT_PATH)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status=200, body=b"{}", headers=None):
|
|
self.status = status
|
|
self._body = body
|
|
self.headers = headers or {}
|
|
|
|
def read(self):
|
|
return self._body
|
|
|
|
|
|
def _write_token_files(tokens_dir: Path, server="stripe", resource="https://mcp.example.com",
|
|
refresh_token="rt-1"):
|
|
tokens_dir.mkdir(parents=True, exist_ok=True)
|
|
tok = {
|
|
"access_token": "at-stored",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": refresh_token,
|
|
"scope": "read",
|
|
"resource": resource,
|
|
"expires_at": 0,
|
|
}
|
|
if refresh_token is None:
|
|
del tok["refresh_token"]
|
|
(tokens_dir / f"{server}.json").write_text(json.dumps(tok))
|
|
(tokens_dir / f"{server}.client.json").write_text(
|
|
json.dumps({"client_id": "cid-1", "token_endpoint_auth_method": "none"})
|
|
)
|
|
return tok
|
|
|
|
|
|
def _run_main(mod, tokens_dir, argv, responses):
|
|
"""Run mod.main() with urlopen mocked; returns captured stdout.
|
|
|
|
``responses`` is a list consumed in call order; each item is either a
|
|
FakeResponse or an Exception to raise.
|
|
"""
|
|
calls = []
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
calls.append(req)
|
|
item = responses.pop(0)
|
|
if isinstance(item, Exception):
|
|
raise item
|
|
return item
|
|
|
|
with patch.object(mod.os, "environ", dict(mod.os.environ, HERMES_HOME=str(tokens_dir.parent))), \
|
|
patch.object(mod.urllib.request, "urlopen", side_effect=fake_urlopen), \
|
|
patch.object(sys, "argv", ["diagnose-oauth-mcp.py", *argv]):
|
|
# Force the env-var fallback path (ignore any importable hermes_constants).
|
|
with patch.object(mod, "_hermes_home", lambda: str(tokens_dir.parent)):
|
|
buf = io.StringIO()
|
|
from contextlib import redirect_stdout
|
|
with redirect_stdout(buf):
|
|
mod.main()
|
|
return buf.getvalue(), calls
|
|
|
|
|
|
def _init_ok_body():
|
|
return json.dumps({"jsonrpc": "2.0", "id": 1,
|
|
"result": {"serverInfo": {"name": "x"}, "capabilities": {}}}).encode()
|
|
|
|
|
|
def _init_revoked_error(code=401):
|
|
body = json.dumps({"error": {"code": -32002, "message": "Session expired. Please re-authenticate."}}).encode()
|
|
return urllib.error.HTTPError("https://mcp.example.com", code, "Unauthorized",
|
|
{"WWW-Authenticate": 'Bearer error="invalid_token"'},
|
|
io.BytesIO(body))
|
|
|
|
|
|
def test_token_ok_branch(tmp_path):
|
|
mod = load_module()
|
|
tokens_dir = tmp_path / "mcp-tokens"
|
|
_write_token_files(tokens_dir)
|
|
out, calls = _run_main(mod, tokens_dir, ["stripe"], [FakeResponse(200, _init_ok_body())])
|
|
assert "BRANCH=TOKEN_OK" in out
|
|
assert len(calls) == 1 # never touched the token endpoint
|
|
|
|
|
|
def test_refresh_dead_no_refresh_token(tmp_path):
|
|
mod = load_module()
|
|
tokens_dir = tmp_path / "mcp-tokens"
|
|
_write_token_files(tokens_dir, refresh_token=None)
|
|
out, _ = _run_main(mod, tokens_dir, ["stripe"], [_init_revoked_error()])
|
|
assert "BRANCH=REFRESH_DEAD" in out
|
|
|
|
|
|
def test_refresh_dead_invalid_grant(tmp_path):
|
|
mod = load_module()
|
|
tokens_dir = tmp_path / "mcp-tokens"
|
|
_write_token_files(tokens_dir)
|
|
grant_err = urllib.error.HTTPError(
|
|
"https://as.example.com/token", 400, "Bad Request", {},
|
|
io.BytesIO(json.dumps({"error": "invalid_grant"}).encode()))
|
|
out, _ = _run_main(
|
|
mod, tokens_dir,
|
|
["stripe", "--token-endpoint", "https://as.example.com/token"],
|
|
[_init_revoked_error(), grant_err],
|
|
)
|
|
assert "BRANCH=REFRESH_DEAD" in out
|
|
assert "invalid_grant" in out
|
|
|
|
|
|
def test_refresh_fixed_branch_without_write_does_not_persist(tmp_path):
|
|
mod = load_module()
|
|
tokens_dir = tmp_path / "mcp-tokens"
|
|
_write_token_files(tokens_dir)
|
|
refreshed = json.dumps({"access_token": "at-new", "token_type": "Bearer",
|
|
"expires_in": 7200, "scope": "read"}).encode()
|
|
out, _ = _run_main(
|
|
mod, tokens_dir,
|
|
["stripe", "--token-endpoint", "https://as.example.com/token"],
|
|
[_init_revoked_error(), FakeResponse(200, refreshed), FakeResponse(200, _init_ok_body())],
|
|
)
|
|
assert "BRANCH=REFRESH_FIXED" in out
|
|
# No --write → stored file untouched
|
|
on_disk = json.loads((tokens_dir / "stripe.json").read_text())
|
|
assert on_disk["access_token"] == "at-stored"
|
|
# Secret values are never printed
|
|
assert "at-new" not in out
|
|
assert "at-stored" not in out
|
|
assert "rt-1" not in out
|
|
|
|
|
|
def test_refresh_fixed_write_persists_atomically(tmp_path):
|
|
mod = load_module()
|
|
tokens_dir = tmp_path / "mcp-tokens"
|
|
_write_token_files(tokens_dir)
|
|
refreshed = json.dumps({"access_token": "at-new", "token_type": "Bearer",
|
|
"expires_in": 7200, "scope": "read write",
|
|
"refresh_token": "rt-rotated"}).encode()
|
|
out, _ = _run_main(
|
|
mod, tokens_dir,
|
|
["stripe", "--token-endpoint", "https://as.example.com/token", "--write"],
|
|
[_init_revoked_error(), FakeResponse(200, refreshed), FakeResponse(200, _init_ok_body())],
|
|
)
|
|
assert "BRANCH=REFRESH_FIXED" in out
|
|
on_disk = json.loads((tokens_dir / "stripe.json").read_text())
|
|
assert on_disk["access_token"] == "at-new"
|
|
assert on_disk["refresh_token"] == "rt-rotated"
|
|
assert on_disk["scope"] == "read write"
|
|
assert on_disk["expires_at"] > 0
|
|
assert not (tokens_dir / "stripe.json.tmp").exists() # atomic replace, no leftover
|
|
mode = (tokens_dir / "stripe.json").stat().st_mode & 0o777
|
|
assert mode == 0o600
|
|
|
|
|
|
def test_session_revoked_branch(tmp_path):
|
|
mod = load_module()
|
|
tokens_dir = tmp_path / "mcp-tokens"
|
|
_write_token_files(tokens_dir)
|
|
refreshed = json.dumps({"access_token": "at-new", "token_type": "Bearer",
|
|
"expires_in": 7200}).encode()
|
|
out, _ = _run_main(
|
|
mod, tokens_dir,
|
|
["stripe", "--token-endpoint", "https://as.example.com/token"],
|
|
[_init_revoked_error(), FakeResponse(200, refreshed), _init_revoked_error()],
|
|
)
|
|
assert "BRANCH=SESSION_REVOKED" in out
|
|
# New token failed too — file must not have been mutated
|
|
on_disk = json.loads((tokens_dir / "stripe.json").read_text())
|
|
assert on_disk["access_token"] == "at-stored"
|
|
|
|
|
|
def test_hermes_home_env_fallback(tmp_path, monkeypatch):
|
|
mod = load_module()
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-home"))
|
|
# Block the hermes_constants import so the env fallback is exercised
|
|
with patch.dict(sys.modules, {"hermes_constants": None}):
|
|
home = mod._hermes_home()
|
|
assert home == str(tmp_path / "custom-home")
|
|
|
|
|
|
def test_requests_send_httpx_user_agent(tmp_path):
|
|
"""Cloudflare 403s bare urllib UAs — every request must carry the httpx UA."""
|
|
mod = load_module()
|
|
tokens_dir = tmp_path / "mcp-tokens"
|
|
_write_token_files(tokens_dir)
|
|
_, calls = _run_main(mod, tokens_dir, ["stripe"], [FakeResponse(200, _init_ok_body())])
|
|
for req in calls:
|
|
assert req.get_header("User-agent") == mod.UA
|
|
|
|
|
|
def test_skill_md_frontmatter_invariants():
|
|
yaml = pytest.importorskip("yaml")
|
|
content = SKILL_MD.read_text()
|
|
assert content.startswith("---\n")
|
|
fm = yaml.safe_load(re.search(r"^---\n(.*?)\n---", content, re.DOTALL).group(1))
|
|
assert len(fm["description"]) <= 60
|
|
assert fm["description"].endswith(".")
|
|
assert "platforms" in fm and len(fm["platforms"]) >= 1
|
|
assert fm["author"].split(",")[0].strip() != "Hermes Agent" # human credited first
|