mirror of
https://github.com/jmagar/unraid-mcp
synced 2026-04-21 13:37:53 +00:00
test: add middleware ordering regression tests, just targets, fix base URL derivation
TestMiddlewareOrdering (6 tests): stacked WellKnown→BearerAuth integration tests that prove the ordering invariant for issue #17. Includes a negative test that explicitly verifies the wrong order (BearerAuth outer) produces 401 on /.well-known/ — the exact regression scenario. Justfile: add test-http, test-http-no-auth, test-http-remote targets. test-http.sh: harden BASE_URL derivation to handle trailing slashes and non-/mcp URL suffixes correctly. Auth test suite now: 42 tests (was 26).
This commit is contained in:
parent
9867b0a3a2
commit
ebf0b0387b
3 changed files with 123 additions and 3 deletions
13
Justfile
13
Justfile
|
|
@ -72,6 +72,19 @@ health:
|
|||
test-live:
|
||||
uv run pytest tests/ -v -m live
|
||||
|
||||
# Run HTTP end-to-end smoke-test against the local server (auto-reads token from ~/.unraid-mcp/.env)
|
||||
test-http:
|
||||
bash tests/mcporter/test-http.sh
|
||||
|
||||
# Run HTTP e2e test with auth disabled (for gateway-protected deployments)
|
||||
test-http-no-auth:
|
||||
bash tests/mcporter/test-http.sh --skip-auth
|
||||
|
||||
# Run HTTP e2e test against a remote URL
|
||||
# Usage: just test-http-remote https://unraid.tootie.tv/mcp
|
||||
test-http-remote url:
|
||||
bash tests/mcporter/test-http.sh --url {{url}} --skip-auth
|
||||
|
||||
# ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Create .env from .env.example if missing
|
||||
|
|
|
|||
|
|
@ -72,9 +72,19 @@ while [[ $# -gt 0 ]]; do
|
|||
esac
|
||||
done
|
||||
|
||||
# Derive base URL (strip /mcp suffix for non-tool endpoints)
|
||||
BASE_URL="${MCP_URL%/mcp}"
|
||||
[[ "$BASE_URL" == "$MCP_URL" ]] && BASE_URL="${MCP_URL%/}"
|
||||
# Derive base URL: strip trailing /mcp (with or without trailing slash).
|
||||
# Examples:
|
||||
# http://localhost:6970/mcp → http://localhost:6970
|
||||
# https://host/api/mcp/ → https://host/api
|
||||
# https://host/mcp → https://host
|
||||
# If the URL doesn't end in /mcp[/], use it as-is (strip trailing slash only).
|
||||
_stripped="${MCP_URL%/}" # remove optional trailing slash
|
||||
if [[ "$_stripped" == */mcp ]]; then
|
||||
BASE_URL="${_stripped%/mcp}"
|
||||
else
|
||||
BASE_URL="$_stripped"
|
||||
fi
|
||||
unset _stripped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-detect token from ~/.unraid-mcp/.env if not supplied
|
||||
|
|
|
|||
|
|
@ -608,3 +608,100 @@ class TestWellKnownMiddleware:
|
|||
|
||||
asyncio.get_event_loop().run_until_complete(mw(scope, receive, send))
|
||||
assert called["value"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stacked middleware integration — ordering invariant (regression for #17)
|
||||
#
|
||||
# WellKnownMiddleware MUST sit outside (before) BearerAuthMiddleware so that
|
||||
# OAuth discovery endpoints are reachable without a token even when auth is
|
||||
# enabled. If the order is swapped the well-known endpoint returns 401 and
|
||||
# MCP clients (e.g. Claude Code) surface an "unknown error".
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _run_stack(path: str, method: str = "GET", auth_header: bytes | None = None) -> int:
|
||||
"""Run the production middleware stack and return the HTTP status code.
|
||||
|
||||
Stack (outermost to innermost): WellKnownMiddleware → BearerAuthMiddleware → app
|
||||
"""
|
||||
app, _ = _app_called_flag()
|
||||
authed_app = BearerAuthMiddleware(app, token="test-token", disabled=False)
|
||||
stacked = WellKnownMiddleware(authed_app)
|
||||
|
||||
headers: list[tuple[bytes, bytes]] = [(b"host", b"localhost:6970")]
|
||||
if auth_header is not None:
|
||||
headers.append((b"authorization", auth_header))
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"scheme": "http",
|
||||
"headers": headers,
|
||||
"client": ("127.0.0.1", 9999),
|
||||
}
|
||||
received: list[dict] = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(msg: dict):
|
||||
received.append(msg)
|
||||
|
||||
await stacked(scope, receive, send)
|
||||
start = next((m for m in received if m["type"] == "http.response.start"), None)
|
||||
return start["status"] if start else 200
|
||||
|
||||
|
||||
class TestMiddlewareOrdering:
|
||||
"""Regression tests for the WellKnown→BearerAuth ordering invariant."""
|
||||
|
||||
def _run(self, path, method="GET", auth_header=None):
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
_run_stack(path, method=method, auth_header=auth_header)
|
||||
)
|
||||
|
||||
def test_well_known_accessible_without_token(self):
|
||||
"""Core regression: discovery must be reachable even with auth enabled."""
|
||||
assert self._run("/.well-known/oauth-protected-resource") == 200
|
||||
|
||||
def test_well_known_mcp_subpath_accessible_without_token(self):
|
||||
assert self._run("/.well-known/oauth-protected-resource/mcp") == 200
|
||||
|
||||
def test_mcp_endpoint_blocked_without_token(self):
|
||||
"""Auth layer must still protect /mcp when no token is provided."""
|
||||
assert self._run("/mcp") == 401
|
||||
|
||||
def test_mcp_endpoint_blocked_with_wrong_token(self):
|
||||
assert self._run("/mcp", auth_header=b"Bearer wrong-token") == 401
|
||||
|
||||
def test_mcp_endpoint_passes_with_correct_token(self):
|
||||
assert self._run("/mcp", auth_header=b"Bearer test-token") == 200
|
||||
|
||||
def test_swapped_order_would_block_well_known(self):
|
||||
"""Negative test: wrong middleware order produces 401 on well-known (issue #17)."""
|
||||
app, _ = _app_called_flag()
|
||||
# Wrong: BearerAuth wraps WellKnown — auth fires before discovery can respond
|
||||
wrong_stack = BearerAuthMiddleware(
|
||||
WellKnownMiddleware(app), token="test-token", disabled=False
|
||||
)
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/.well-known/oauth-protected-resource",
|
||||
"scheme": "http",
|
||||
"headers": [(b"host", b"localhost:6970")],
|
||||
"client": ("127.0.0.1", 9999),
|
||||
}
|
||||
received: list[dict] = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(msg: dict):
|
||||
received.append(msg)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(wrong_stack(scope, receive, send))
|
||||
start = next(m for m in received if m["type"] == "http.response.start")
|
||||
assert start["status"] == 401, "Wrong order should produce 401 (the issue #17 regression)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue