mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com> Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com> Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com> Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: nightcityblade <nightcityblade@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Bill Easton <strawgate@users.noreply.github.com> Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com> Co-authored-by: Eric Robinson <ericrobinson@indeed.com> Co-authored-by: Martim Santos <martimfasantos@gmail.com> Co-authored-by: d 🔹 <liusway405@gmail.com> Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com> Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com> Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com> Co-authored-by: nightcityblade <jackchen@haloailabs.com> Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com> Co-authored-by: nate nowack <thrast36@gmail.com> Co-authored-by: Bill Easton <williamseaston@gmail.com> Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com> Co-authored-by: Rushabh Doshi <radoshi@gmail.com> Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp> Co-authored-by: Jeremy Simon <simonjer805@gmail.com> Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com> Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com> Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com> Fix auto-close MRE script posting comment without closing (#3386) Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407) Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413) Fix transform arg collisions with passthrough params (#3431) Fix get_* returning None when latest version is disabled (#3439) Fix get_* returning None when latest version is disabled (#3421) Fix server lifespan overlap teardown (#3415) Fix $ref output schema object detection regression (#3420) resolved annotations (#3429) Fix async partial callables rejected by iscoroutinefunction (#3438) Fix async partial callables rejected by iscoroutinefunction (#3423) fix: add version to components (#3458) fix: use intent-based flag for OIDC scope patch in load_access_token (#3465) Fixes #3461 fix: normalize Google scope shorthands and surface valid_scopes (#3477) fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481) fix: shield lifespan teardown from cancellation (#3480) fix: forward custom_route endpoints from mounted servers (#3462) fix updates _get_additional_http_routes() to traverse providers, Fixes #3457 fix: remove hardcoded version from CLI help text (#3456) fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468) fix: task test teardown hanging 5s per test (#3499) Closes #3498 fix: validate workspace path is a directory before cursor install (#3440) Fixes #3426 fix: handle re.error from malformed URI templates in build_regex (#3501) fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503) fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502) fix warnings and timeouts (#3504) close upgrade check issue when build passes (#3505) Closes #3484 fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507) fix: prevent path traversal in skill download (#3493) fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492) fix: remove unrelated transform and http.py changes from PR scope fix: remove forced follow_redirects from httpx_client_factory calls (#3496) fix: stop passing follow_redirects to httpx_client_factory fix: restore follow_redirects=True for custom httpx client factories Closes #3509 fix: CSRF double-submit cookie check in consent flow (#3519) fix: validate server names in install commands (#3522) fix: use raw strings for regex in pytest.raises match (#3523) fix: reject refresh tokens used as Bearer access tokens (#3524) fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495) fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540) fix: filter warnings by message in KEY_PREFIX test (#3549) fix: suppress output schema for ToolResult subclass annotations (#3548) fix: increase sleep duration in proxy cache tests (#3567) fix: store absolute token expiry to prevent stale expires_in on reload (#3572) fix: preserve tool properties named 'title' during schema compression (#3582) Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589) Fix app tool routing: visibility check and middleware propagation (#3591) Fix query parameter serialization to respect OpenAPI explode/style settings (#3595) Fix dev apps form: union types, textarea support, JSON parsing (#3597) fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603) fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609) fix(docs): correct misleading stateless_http header (#3622) fix: filesystem provider import machinery (#3626) Closes #3625 (issues 2, 3, 6) fix: recover StdioTransport after subprocess exits (#3630) fix(server): preserve mounted tool task metadata (#3632) fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649) fix imports, add PrefabAppConfig (#3650) fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651) Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652) closed by default (#3657) Fix prompt caching middleware missing wrap/unwrap round-trip (#3666) fix: serialize object query params per OpenAPI style/explode rules (#3662) Fixes #2857 fix: HTTP request headers not accessible in background task workers (#3631) fix: restore HTTP headers in worker execution path for background tasks (#3681) fix: strip discriminator after dereferencing schemas (#3682) fix: remove stale ty:ignore directives for ty 0.0.26 (#3684) Fix docs gaps in app provider pages (#3690) fix: dev apps log panel UX improvements (#3698) fix dev server empty string args (#3700)
552 lines
21 KiB
Python
552 lines
21 KiB
Python
"""Unit tests for CIMD (Client ID Metadata Document) functionality."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from pydantic import AnyHttpUrl, ValidationError
|
|
|
|
from fastmcp.server.auth.cimd import (
|
|
CIMDDocument,
|
|
CIMDFetcher,
|
|
CIMDFetchError,
|
|
CIMDValidationError,
|
|
)
|
|
|
|
# Standard public IP used for DNS mocking in tests
|
|
TEST_PUBLIC_IP = "93.184.216.34"
|
|
|
|
|
|
class TestCIMDDocument:
|
|
"""Tests for CIMDDocument model validation."""
|
|
|
|
def test_valid_minimal_document(self):
|
|
"""Test that minimal valid document passes validation."""
|
|
doc = CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
)
|
|
assert str(doc.client_id) == "https://example.com/client.json"
|
|
assert doc.token_endpoint_auth_method == "none"
|
|
assert doc.grant_types == ["authorization_code"]
|
|
assert doc.response_types == ["code"]
|
|
|
|
def test_valid_full_document(self):
|
|
"""Test that full document passes validation."""
|
|
doc = CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
client_name="My App",
|
|
client_uri=AnyHttpUrl("https://example.com"),
|
|
logo_uri=AnyHttpUrl("https://example.com/logo.png"),
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
token_endpoint_auth_method="none",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
scope="read write",
|
|
)
|
|
assert doc.client_name == "My App"
|
|
assert doc.scope == "read write"
|
|
|
|
def test_private_key_jwt_auth_method_allowed(self):
|
|
"""Test that private_key_jwt is allowed for CIMD."""
|
|
doc = CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
token_endpoint_auth_method="private_key_jwt",
|
|
jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
|
|
)
|
|
assert doc.token_endpoint_auth_method == "private_key_jwt"
|
|
|
|
def test_client_secret_basic_rejected(self):
|
|
"""Test that client_secret_basic is rejected for CIMD."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
token_endpoint_auth_method="client_secret_basic", # type: ignore[arg-type] - testing invalid value # ty:ignore[invalid-argument-type]
|
|
)
|
|
# Literal type rejects invalid values before custom validator
|
|
assert "token_endpoint_auth_method" in str(exc_info.value)
|
|
|
|
def test_client_secret_post_rejected(self):
|
|
"""Test that client_secret_post is rejected for CIMD."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
token_endpoint_auth_method="client_secret_post", # type: ignore[arg-type] - testing invalid value # ty:ignore[invalid-argument-type]
|
|
)
|
|
assert "token_endpoint_auth_method" in str(exc_info.value)
|
|
|
|
def test_client_secret_jwt_rejected(self):
|
|
"""Test that client_secret_jwt is rejected for CIMD."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
token_endpoint_auth_method="client_secret_jwt", # type: ignore[arg-type] - testing invalid value # ty:ignore[invalid-argument-type]
|
|
)
|
|
assert "token_endpoint_auth_method" in str(exc_info.value)
|
|
|
|
def test_missing_redirect_uris_rejected(self):
|
|
"""Test that redirect_uris is required for CIMD."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
CIMDDocument(client_id=AnyHttpUrl("https://example.com/client.json"))
|
|
assert "redirect_uris" in str(exc_info.value)
|
|
|
|
def test_empty_redirect_uris_rejected(self):
|
|
"""Test that empty redirect_uris is rejected."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=[],
|
|
)
|
|
assert "redirect_uris" in str(exc_info.value)
|
|
|
|
def test_redirect_uri_without_scheme_rejected(self):
|
|
"""Test that redirect_uris without a scheme are rejected."""
|
|
with pytest.raises(ValidationError, match="must have a scheme"):
|
|
CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["/just/a/path"],
|
|
)
|
|
|
|
def test_redirect_uri_without_host_rejected(self):
|
|
"""Test that redirect_uris without a host are rejected."""
|
|
with pytest.raises(ValidationError, match="must have a host"):
|
|
CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://"],
|
|
)
|
|
|
|
def test_redirect_uri_whitespace_only_rejected(self):
|
|
"""Test that whitespace-only redirect_uris are rejected."""
|
|
with pytest.raises(ValidationError, match="non-empty"):
|
|
CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=[" "],
|
|
)
|
|
|
|
|
|
class TestCIMDFetcher:
|
|
"""Tests for CIMDFetcher."""
|
|
|
|
@pytest.fixture
|
|
def fetcher(self):
|
|
"""Create a CIMDFetcher for testing."""
|
|
return CIMDFetcher()
|
|
|
|
def test_is_cimd_client_id_valid_urls(self, fetcher: CIMDFetcher):
|
|
"""Test is_cimd_client_id accepts valid CIMD URLs."""
|
|
assert fetcher.is_cimd_client_id("https://example.com/client.json")
|
|
assert fetcher.is_cimd_client_id("https://example.com/path/to/client")
|
|
assert fetcher.is_cimd_client_id("https://sub.example.com/cimd.json")
|
|
|
|
def test_is_cimd_client_id_rejects_http(self, fetcher: CIMDFetcher):
|
|
"""Test is_cimd_client_id rejects HTTP URLs."""
|
|
assert not fetcher.is_cimd_client_id("http://example.com/client.json")
|
|
|
|
def test_is_cimd_client_id_rejects_root_path(self, fetcher: CIMDFetcher):
|
|
"""Test is_cimd_client_id rejects URLs with no path."""
|
|
assert not fetcher.is_cimd_client_id("https://example.com/")
|
|
assert not fetcher.is_cimd_client_id("https://example.com")
|
|
|
|
def test_is_cimd_client_id_rejects_non_url(self, fetcher: CIMDFetcher):
|
|
"""Test is_cimd_client_id rejects non-URL strings."""
|
|
assert not fetcher.is_cimd_client_id("client-123")
|
|
assert not fetcher.is_cimd_client_id("my-client")
|
|
assert not fetcher.is_cimd_client_id("")
|
|
assert not fetcher.is_cimd_client_id("not a url")
|
|
|
|
def test_validate_redirect_uri_exact_match(self, fetcher: CIMDFetcher):
|
|
"""Test redirect_uri validation with exact match."""
|
|
doc = CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost:3000/callback"],
|
|
)
|
|
assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback")
|
|
assert not fetcher.validate_redirect_uri(doc, "http://localhost:4000/callback")
|
|
|
|
def test_validate_redirect_uri_wildcard_match(self, fetcher: CIMDFetcher):
|
|
"""Test redirect_uri validation with wildcard port."""
|
|
doc = CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost:*/callback"],
|
|
)
|
|
assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback")
|
|
assert fetcher.validate_redirect_uri(doc, "http://localhost:8080/callback")
|
|
assert not fetcher.validate_redirect_uri(doc, "http://localhost:3000/other")
|
|
|
|
def test_validate_redirect_uri_loopback_no_port(self, fetcher: CIMDFetcher):
|
|
"""RFC 8252 §7.3: loopback URI without port should match any port."""
|
|
doc = CIMDDocument(
|
|
client_id=AnyHttpUrl("https://example.com/client.json"),
|
|
redirect_uris=["http://localhost/callback", "http://127.0.0.1/callback"],
|
|
)
|
|
assert fetcher.validate_redirect_uri(doc, "http://localhost:51353/callback")
|
|
assert fetcher.validate_redirect_uri(doc, "http://127.0.0.1:3000/callback")
|
|
assert not fetcher.validate_redirect_uri(doc, "http://localhost:51353/other")
|
|
|
|
|
|
class TestCIMDFetcherHTTP:
|
|
"""Tests for CIMDFetcher HTTP fetching (using httpx mock).
|
|
|
|
Note: With SSRF protection and DNS pinning, HTTP requests go to the resolved IP
|
|
instead of the hostname. These tests mock DNS resolution to return a public IP
|
|
and configure httpx_mock to expect the IP-based URL.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def fetcher(self):
|
|
"""Create a CIMDFetcher for testing."""
|
|
return CIMDFetcher()
|
|
|
|
@pytest.fixture
|
|
def mock_dns(self):
|
|
"""Mock DNS resolution to return test public IP."""
|
|
with patch(
|
|
"fastmcp.server.auth.ssrf.resolve_hostname",
|
|
return_value=[TEST_PUBLIC_IP],
|
|
):
|
|
yield
|
|
|
|
async def test_fetch_success(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
|
|
"""Test successful CIMD document fetch."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "Test App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
|
|
# With DNS pinning, request goes to IP. Match any URL.
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={
|
|
"content-type": "application/json",
|
|
"content-length": "200",
|
|
},
|
|
)
|
|
|
|
doc = await fetcher.fetch(url)
|
|
assert str(doc.client_id) == url
|
|
assert doc.client_name == "Test App"
|
|
|
|
async def test_fetch_ttl_cache(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
|
|
"""Test that fetched documents are cached and served from cache within TTL."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "Test App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={"content-length": "200"},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
second = await fetcher.fetch(url)
|
|
|
|
assert first.client_id == second.client_id
|
|
assert len(httpx_mock.get_requests()) == 1
|
|
|
|
async def test_fetch_cache_control_max_age(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""Cache-Control max-age should prevent refetch before expiry."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "Max-Age App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={"cache-control": "max-age=60", "content-length": "200"},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
second = await fetcher.fetch(url)
|
|
|
|
assert first.client_name == second.client_name
|
|
assert len(httpx_mock.get_requests()) == 1
|
|
|
|
async def test_fetch_etag_revalidation_304(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""Expired cache should revalidate with ETag and accept 304."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "ETag App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={
|
|
"cache-control": "max-age=0",
|
|
"etag": '"v1"',
|
|
"content-length": "200",
|
|
},
|
|
)
|
|
httpx_mock.add_response(
|
|
status_code=304,
|
|
headers={
|
|
"cache-control": "max-age=120",
|
|
"etag": '"v1"',
|
|
"content-length": "0",
|
|
},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
second = await fetcher.fetch(url)
|
|
requests = httpx_mock.get_requests()
|
|
|
|
assert first.client_name == "ETag App"
|
|
assert second.client_name == "ETag App"
|
|
assert len(requests) == 2
|
|
assert requests[1].headers.get("if-none-match") == '"v1"'
|
|
|
|
async def test_fetch_last_modified_revalidation_304(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""Expired cache should revalidate with Last-Modified and accept 304."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "Last-Modified App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
last_modified = "Wed, 21 Oct 2015 07:28:00 GMT"
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={
|
|
"cache-control": "max-age=0",
|
|
"last-modified": last_modified,
|
|
"content-length": "200",
|
|
},
|
|
)
|
|
httpx_mock.add_response(
|
|
status_code=304,
|
|
headers={"cache-control": "max-age=120", "content-length": "0"},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
second = await fetcher.fetch(url)
|
|
requests = httpx_mock.get_requests()
|
|
|
|
assert first.client_name == "Last-Modified App"
|
|
assert second.client_name == "Last-Modified App"
|
|
assert len(requests) == 2
|
|
assert requests[1].headers.get("if-modified-since") == last_modified
|
|
|
|
async def test_fetch_cache_control_no_store(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""Cache-Control no-store should prevent storing CIMD documents."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "No-Store App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={"cache-control": "no-store", "content-length": "200"},
|
|
)
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={"cache-control": "no-store", "content-length": "200"},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
second = await fetcher.fetch(url)
|
|
|
|
assert first.client_name == second.client_name
|
|
assert len(httpx_mock.get_requests()) == 2
|
|
|
|
async def test_fetch_cache_control_no_cache(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""Cache-Control no-cache should force revalidation on each fetch."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "No-Cache App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={
|
|
"cache-control": "no-cache",
|
|
"etag": '"v2"',
|
|
"content-length": "200",
|
|
},
|
|
)
|
|
httpx_mock.add_response(
|
|
status_code=304,
|
|
headers={
|
|
"cache-control": "no-cache",
|
|
"etag": '"v2"',
|
|
"content-length": "0",
|
|
},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
second = await fetcher.fetch(url)
|
|
requests = httpx_mock.get_requests()
|
|
|
|
assert first.client_name == "No-Cache App"
|
|
assert second.client_name == "No-Cache App"
|
|
assert len(requests) == 2
|
|
assert requests[1].headers.get("if-none-match") == '"v2"'
|
|
|
|
async def test_fetch_304_without_cache_headers_preserves_policy(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""304 responses without cache headers should not reset cached policy."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "No-Header-304 App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={
|
|
"cache-control": "no-cache",
|
|
"etag": '"v3"',
|
|
"content-length": "200",
|
|
},
|
|
)
|
|
# Intentionally omit cache-control/expires on 304.
|
|
httpx_mock.add_response(
|
|
status_code=304,
|
|
headers={"content-length": "0"},
|
|
)
|
|
httpx_mock.add_response(
|
|
status_code=304,
|
|
headers={"content-length": "0"},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
second = await fetcher.fetch(url)
|
|
third = await fetcher.fetch(url)
|
|
requests = httpx_mock.get_requests()
|
|
|
|
assert first.client_name == "No-Header-304 App"
|
|
assert second.client_name == "No-Header-304 App"
|
|
assert third.client_name == "No-Header-304 App"
|
|
assert len(requests) == 3
|
|
assert requests[1].headers.get("if-none-match") == '"v3"'
|
|
assert requests[2].headers.get("if-none-match") == '"v3"'
|
|
|
|
async def test_fetch_304_without_cache_headers_refreshes_cached_freshness(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""A header-less 304 should renew freshness using cached lifetime."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"client_name": "Headerless 304 Freshness App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={
|
|
"cache-control": "max-age=60",
|
|
"etag": '"v4"',
|
|
"content-length": "200",
|
|
},
|
|
)
|
|
httpx_mock.add_response(
|
|
status_code=304,
|
|
headers={"content-length": "0"},
|
|
)
|
|
|
|
first = await fetcher.fetch(url)
|
|
|
|
# Simulate cache expiry so the next request triggers revalidation.
|
|
cached_entry = fetcher._cache[url]
|
|
cached_entry.expires_at = time.time() - 1
|
|
|
|
second = await fetcher.fetch(url)
|
|
third = await fetcher.fetch(url)
|
|
requests = httpx_mock.get_requests()
|
|
|
|
assert first.client_name == "Headerless 304 Freshness App"
|
|
assert second.client_name == "Headerless 304 Freshness App"
|
|
assert third.client_name == "Headerless 304 Freshness App"
|
|
assert len(requests) == 2
|
|
assert requests[1].headers.get("if-none-match") == '"v4"'
|
|
|
|
async def test_fetch_client_id_mismatch(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""Test that client_id mismatch is rejected."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": "https://other.com/client.json", # Different URL
|
|
"client_name": "Test App",
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={"content-length": "100"},
|
|
)
|
|
|
|
with pytest.raises(CIMDValidationError) as exc_info:
|
|
await fetcher.fetch(url)
|
|
assert "mismatch" in str(exc_info.value).lower()
|
|
|
|
async def test_fetch_http_error(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
|
|
"""Test handling of HTTP errors."""
|
|
url = "https://example.com/client.json"
|
|
httpx_mock.add_response(status_code=404)
|
|
|
|
with pytest.raises(CIMDFetchError) as exc_info:
|
|
await fetcher.fetch(url)
|
|
assert "404" in str(exc_info.value)
|
|
|
|
async def test_fetch_invalid_json(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
|
|
"""Test handling of invalid JSON response."""
|
|
url = "https://example.com/client.json"
|
|
httpx_mock.add_response(
|
|
content=b"not json",
|
|
headers={"content-length": "10"},
|
|
)
|
|
|
|
with pytest.raises(CIMDValidationError) as exc_info:
|
|
await fetcher.fetch(url)
|
|
assert "JSON" in str(exc_info.value)
|
|
|
|
async def test_fetch_invalid_document(
|
|
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
|
|
):
|
|
"""Test handling of invalid CIMD document."""
|
|
url = "https://example.com/client.json"
|
|
doc_data = {
|
|
"client_id": url,
|
|
"redirect_uris": ["http://localhost:3000/callback"],
|
|
"token_endpoint_auth_method": "client_secret_basic", # Not allowed
|
|
}
|
|
httpx_mock.add_response(
|
|
json=doc_data,
|
|
headers={"content-length": "100"},
|
|
)
|
|
|
|
with pytest.raises(CIMDValidationError) as exc_info:
|
|
await fetcher.fetch(url)
|
|
assert "Invalid CIMD document" in str(exc_info.value)
|