Files
AegisGitea-MCP/tests/test_auth.py
T
Latte c9e2c481e7 feat: add MCP server core, stdio and streamable HTTP transports and serve command
Low-level SDK server built from the catalog plus gitea_api_request, with TOOLSETS/READ_ONLY/DISABLED_TOOLS filters, schema validation without value echo, one audit event per call, static bearer verifier, identity resolver hook, /health, DNS-rebinding protection, rate limiting and fail-closed startup checks.
2026-10-14 16:13:11 +02:00

174 lines
6.0 KiB
Python

"""Tests for the static bearer verifier, composite verifier and identity resolution."""
from __future__ import annotations
import hmac
from typing import Any
import pytest
from mcp.server.auth.provider import AccessToken
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp import auth as auth_module
from aegis_gitea_mcp.auth import (
STATIC_CLIENT_ID,
AdminIdentityResolver,
CompositeVerifier,
StaticBearerVerifier,
)
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.gitea import GiteaExecutor
from tests.test_server_helpers import API, BEARER_TOKEN, GITEA_TOKEN, server_settings
# Static bearer verifier
async def test_correct_token_yields_an_admin_access_token_without_the_secret() -> None:
access = await StaticBearerVerifier(BEARER_TOKEN).verify_token(BEARER_TOKEN)
assert access is not None
assert access.client_id == STATIC_CLIENT_ID == "aegis-static-bearer"
assert access.scopes == ["gitea"]
assert access.subject == "admin"
assert access.token == "static"
assert BEARER_TOKEN not in repr(access)
@pytest.mark.parametrize(
"presented",
[
"",
"x",
BEARER_TOKEN + "x",
BEARER_TOKEN[:-1],
BEARER_TOKEN.upper(),
"Bearer " + BEARER_TOKEN,
],
)
async def test_wrong_tokens_are_rejected(presented: str) -> None:
assert await StaticBearerVerifier(BEARER_TOKEN).verify_token(presented) is None
async def test_comparison_is_constant_time_even_for_different_lengths(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[bytes, bytes]] = []
real = hmac.compare_digest
def spy(left: Any, right: Any) -> bool:
calls.append((left, right))
return real(left, right)
monkeypatch.setattr(auth_module.hmac, "compare_digest", spy)
verifier = StaticBearerVerifier(BEARER_TOKEN)
await verifier.verify_token("short")
await verifier.verify_token(BEARER_TOKEN + "-much-longer-than-expected")
await verifier.verify_token(BEARER_TOKEN)
assert len(calls) == 3
# Digests of equal length are compared, never the raw secrets.
assert all(len(left) == len(right) == 32 for left, right in calls)
assert all(BEARER_TOKEN.encode() not in pair for pair in calls)
async def test_composite_verifier_returns_first_match() -> None:
other = AccessToken(token="t", client_id="oauth-client", scopes=["x"])
class Accepts:
async def verify_token(self, token: str) -> AccessToken | None:
return other if token == "oauth-token" else None
composite = CompositeVerifier(StaticBearerVerifier(BEARER_TOKEN), Accepts())
static = await composite.verify_token(BEARER_TOKEN)
oauth = await composite.verify_token("oauth-token")
assert static is not None and static.client_id == STATIC_CLIENT_ID
assert oauth is other
assert await composite.verify_token("nothing") is None
assert await CompositeVerifier().verify_token(BEARER_TOKEN) is None
# Identity resolver
async def test_no_access_token_resolves_to_the_admin_identity(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
identity = await resolver.resolve(None)
assert (identity.login, identity.mode, identity.token) == ("root", "admin-token", GITEA_TOKEN)
assert GITEA_TOKEN not in repr(identity)
assert httpx_mock.get_requests()[0].headers["Authorization"] == f"token {GITEA_TOKEN}"
async def test_login_is_looked_up_once_and_cached(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
access = AccessToken(token="static", client_id=STATIC_CLIENT_ID, scopes=["gitea"])
first = await resolver.resolve(access)
second = await resolver.resolve(access)
assert first.login == second.login == "root"
assert len(httpx_mock.get_requests()) == 1
@pytest.mark.parametrize(
"response",
[
{"status_code": 401, "json": {"message": "bad token"}},
{"status_code": 200, "json": ["not", "an", "object"]},
{"status_code": 200, "json": {"login": ""}},
{"status_code": 200, "json": {"login": 5}},
],
)
async def test_failed_lookup_leaves_the_login_unknown_and_is_throttled(
httpx_mock: HTTPXMock, response: dict[str, Any]
) -> None:
httpx_mock.add_response(url=f"{API}/user", **response)
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
first = await resolver.resolve(None)
second = await resolver.resolve(None)
assert first.login is None and second.login is None
assert first.mode == "admin-token" and first.token == GITEA_TOKEN
assert len(httpx_mock.get_requests()) == 1 # no retry inside the back-off window
async def test_lookup_is_retried_after_the_backoff(
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
httpx_mock.add_response(url=f"{API}/user", status_code=500)
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
clock = [1000.0]
monkeypatch.setattr(auth_module.time, "monotonic", lambda: clock[0])
assert (await resolver.resolve(None)).login is None
clock[0] += auth_module._LOGIN_RETRY_SECONDS + 1
assert (await resolver.resolve(None)).login == "root"
async def test_other_client_ids_are_refused() -> None:
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
with pytest.raises(ToolError, match="unknown identity"):
await resolver.resolve(AccessToken(token="x", client_id="oauth-client", scopes=[]))
async def test_missing_gitea_token_is_refused() -> None:
settings = server_settings(gitea_token=None)
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
with pytest.raises(ToolError, match="GITEA_TOKEN"):
await resolver.resolve(None)