test: Refactor test fixtures into a oauth token factory
This commit is contained in:
+110
-274
@@ -58,6 +58,69 @@ async def wait_for_nextcloud(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def create_mcp_client_session(
|
||||||
|
url: str,
|
||||||
|
token: str | None = None,
|
||||||
|
client_name: str = "MCP",
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""
|
||||||
|
Factory function to create an MCP client session with proper lifecycle management.
|
||||||
|
|
||||||
|
Consolidates the common pattern used by all MCP client fixtures:
|
||||||
|
- Creates streamable HTTP client with optional OAuth token
|
||||||
|
- Initializes MCP ClientSession
|
||||||
|
- Handles cleanup with proper exception handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: MCP server URL (e.g., "http://127.0.0.1:8000/mcp")
|
||||||
|
token: Optional OAuth access token for Bearer authentication
|
||||||
|
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Initialized MCP ClientSession
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating Streamable HTTP client for {client_name}")
|
||||||
|
|
||||||
|
# Prepare headers with OAuth token if provided
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||||
|
streamable_context = streamablehttp_client(url, headers=headers)
|
||||||
|
session_context = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||||
|
session_context = ClientSession(read_stream, write_stream)
|
||||||
|
session = await session_context.__aenter__()
|
||||||
|
await session.initialize()
|
||||||
|
logger.info(f"{client_name} client session initialized successfully")
|
||||||
|
|
||||||
|
yield session
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up in reverse order, ignoring task scope issues
|
||||||
|
if session_context is not None:
|
||||||
|
try:
|
||||||
|
await session_context.__aexit__(None, None, None)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "cancel scope" in str(e):
|
||||||
|
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Error closing {client_name} session: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing {client_name} session: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await streamable_context.__aexit__(None, None, None)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "cancel scope" in str(e):
|
||||||
|
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Error closing {client_name} streamable HTTP client: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing {client_name} streamable HTTP client: {e}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -98,42 +161,11 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
|||||||
"""
|
"""
|
||||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||||
"""
|
"""
|
||||||
logger.info("Creating Streamable HTTP client")
|
async for session in create_mcp_client_session(
|
||||||
streamable_context = streamablehttp_client("http://127.0.0.1:8000/mcp")
|
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
|
||||||
session_context = None
|
):
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("MCP client session initialized successfully")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up in reverse order, ignoring task scope issues
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Error closing session: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing session: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def nc_mcp_oauth_client_interactive(
|
async def nc_mcp_oauth_client_interactive(
|
||||||
@@ -148,52 +180,13 @@ async def nc_mcp_oauth_client_interactive(
|
|||||||
|
|
||||||
Automatically skips when running in GitHub Actions CI.
|
Automatically skips when running in GitHub Actions CI.
|
||||||
"""
|
"""
|
||||||
|
async for session in create_mcp_client_session(
|
||||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Interactive)")
|
url="http://127.0.0.1:8001/mcp",
|
||||||
|
token=interactive_oauth_token,
|
||||||
# Pass OAuth token as Bearer token in headers
|
client_name="OAuth MCP (Interactive)",
|
||||||
headers = {"Authorization": f"Bearer {interactive_oauth_token}"}
|
):
|
||||||
streamable_context = streamablehttp_client(
|
|
||||||
"http://127.0.0.1:8001/mcp", headers=headers
|
|
||||||
)
|
|
||||||
session_context = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("OAuth MCP client session (Interactive) initialized successfully")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up in reverse order, ignoring task scope issues
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Error closing OAuth session (Interactive): {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing OAuth session (Interactive): {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Error closing OAuth streamable HTTP client (Interactive): {e}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Error closing OAuth streamable HTTP client (Interactive): {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def nc_mcp_oauth_client(
|
async def nc_mcp_oauth_client(
|
||||||
@@ -206,51 +199,13 @@ async def nc_mcp_oauth_client(
|
|||||||
This is the default OAuth MCP fixture using headless browser automation suitable for CI/CD.
|
This is the default OAuth MCP fixture using headless browser automation suitable for CI/CD.
|
||||||
For interactive testing with manual browser login, use nc_mcp_oauth_client_interactive instead.
|
For interactive testing with manual browser login, use nc_mcp_oauth_client_interactive instead.
|
||||||
"""
|
"""
|
||||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)")
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://127.0.0.1:8001/mcp",
|
||||||
# Pass OAuth token as Bearer token in headers
|
token=playwright_oauth_token,
|
||||||
headers = {"Authorization": f"Bearer {playwright_oauth_token}"}
|
client_name="OAuth MCP (Playwright)",
|
||||||
streamable_context = streamablehttp_client(
|
):
|
||||||
"http://127.0.0.1:8001/mcp", headers=headers
|
|
||||||
)
|
|
||||||
session_context = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("OAuth MCP client session (Playwright) initialized successfully")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up in reverse order, ignoring task scope issues
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def temporary_note(nc_client: NextcloudClient):
|
async def temporary_note(nc_client: NextcloudClient):
|
||||||
@@ -1089,51 +1044,13 @@ async def nc_mcp_oauth_client_playwright(
|
|||||||
This fixture uses headless browser automation and is suitable for CI/CD pipelines.
|
This fixture uses headless browser automation and is suitable for CI/CD pipelines.
|
||||||
For interactive testing, use nc_mcp_oauth_client fixture instead.
|
For interactive testing, use nc_mcp_oauth_client fixture instead.
|
||||||
"""
|
"""
|
||||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)")
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://127.0.0.1:8001/mcp",
|
||||||
# Pass OAuth token as Bearer token in headers
|
token=playwright_oauth_token,
|
||||||
headers = {"Authorization": f"Bearer {playwright_oauth_token}"}
|
client_name="OAuth MCP (Playwright Alt)",
|
||||||
streamable_context = streamablehttp_client(
|
):
|
||||||
"http://127.0.0.1:8001/mcp", headers=headers
|
|
||||||
)
|
|
||||||
session_context = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("OAuth MCP client session (Playwright) initialized successfully")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up in reverse order, ignoring task scope issues
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "cancel scope" in str(e):
|
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def test_users_setup(nc_client: NextcloudClient):
|
async def test_users_setup(nc_client: NextcloudClient):
|
||||||
@@ -1416,7 +1333,7 @@ async def all_oauth_tokens(
|
|||||||
config["password"],
|
config["password"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create tasks for all users with staggered starts (2.0s apart)
|
# Create tasks for all users with staggered starts (0.5s apart)
|
||||||
tasks = {
|
tasks = {
|
||||||
username: get_token_with_delay(username, config, idx * 0.5)
|
username: get_token_with_delay(username, config, idx * 0.5)
|
||||||
for idx, (username, config) in enumerate(test_users_setup.items())
|
for idx, (username, config) in enumerate(test_users_setup.items())
|
||||||
@@ -1467,133 +1384,52 @@ async def diana_oauth_token(all_oauth_tokens) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def alice_mcp_client(alice_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
async def alice_mcp_client(
|
||||||
|
alice_oauth_token: str,
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as alice (owner role)."""
|
"""MCP client authenticated as alice (owner role)."""
|
||||||
token = alice_oauth_token
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://127.0.0.1:8001/mcp",
|
||||||
# Create MCP client session with proper lifecycle management
|
token=alice_oauth_token,
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
client_name="Alice MCP",
|
||||||
streamable_context = streamablehttp_client(
|
):
|
||||||
"http://127.0.0.1:8001/mcp", headers=headers
|
|
||||||
)
|
|
||||||
session_context = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("Alice MCP client session initialized")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing alice session: {e}")
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing alice streamable context: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def bob_mcp_client(bob_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
async def bob_mcp_client(bob_oauth_token: str) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as bob (viewer role)."""
|
"""MCP client authenticated as bob (viewer role)."""
|
||||||
token = bob_oauth_token
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://127.0.0.1:8001/mcp", token=bob_oauth_token, client_name="Bob MCP"
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
):
|
||||||
streamable_context = streamablehttp_client(
|
|
||||||
"http://127.0.0.1:8001/mcp", headers=headers
|
|
||||||
)
|
|
||||||
session_context = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("Bob MCP client session initialized")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing bob session: {e}")
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing bob streamable context: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def charlie_mcp_client(charlie_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
async def charlie_mcp_client(
|
||||||
|
charlie_oauth_token: str,
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
||||||
token = charlie_oauth_token
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://127.0.0.1:8001/mcp",
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
token=charlie_oauth_token,
|
||||||
streamable_context = streamablehttp_client(
|
client_name="Charlie MCP",
|
||||||
"http://127.0.0.1:8001/mcp", headers=headers
|
):
|
||||||
)
|
|
||||||
session_context = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("Charlie MCP client session initialized")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing charlie session: {e}")
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing charlie streamable context: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def diana_mcp_client(diana_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
async def diana_mcp_client(
|
||||||
|
diana_oauth_token: str,
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as diana (no-access role)."""
|
"""MCP client authenticated as diana (no-access role)."""
|
||||||
token = diana_oauth_token
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://127.0.0.1:8001/mcp",
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
token=diana_oauth_token,
|
||||||
streamable_context = streamablehttp_client(
|
client_name="Diana MCP",
|
||||||
"http://127.0.0.1:8001/mcp", headers=headers
|
):
|
||||||
)
|
|
||||||
session_context = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("Diana MCP client session initialized")
|
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
finally:
|
|
||||||
if session_context is not None:
|
|
||||||
try:
|
|
||||||
await session_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing diana session: {e}")
|
|
||||||
try:
|
|
||||||
await streamable_context.__aexit__(None, None, None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error closing diana streamable context: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Test user/group fixtures for clean test isolation
|
# Test user/group fixtures for clean test isolation
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
Reference in New Issue
Block a user