diff --git a/tests/test_persimmons_juice_http.py b/tests/test_persimmons_juice_http.py new file mode 100644 index 0000000..d7f34aa --- /dev/null +++ b/tests/test_persimmons_juice_http.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +import json +import secrets +import unittest +from unittest.mock import AsyncMock, patch + +import httpx + +os.environ.setdefault("GITEA_TOKEN", "test-token") +os.environ.setdefault("GITEA_OAUTH_CLIENT_ID", "test-client") +os.environ.setdefault("GITEA_OAUTH_CLIENT_SECRET", "test-secret") +os.environ.setdefault("JWT_SIGNING_KEY", "test-signing-key-for-unit-tests") + +from tea_mcp import server +from tea_mcp.persimmons import create_connection, create_juice +from fastmcp import Client, FastMCP +from tests.test_persimmons import FakeGitea + + +class JuiceHttpTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.client = httpx.AsyncClient(transport=httpx.ASGITransport(app=server.app), base_url="https://example.invalid") + + async def asyncTearDown(self): + await self.client.aclose() + + async def test_unauthorized_routes_do_not_access_storage_or_duplicate_proxy_cors(self): + with patch.object(server, "persimmons_actor", AsyncMock(return_value=None)), patch.object(server, "persimmons_requester") as requester: + for operation, method in (("list", "GET"), ("create", "POST"), ("revoke", "POST")): + response = await self.client.request(method, "/chat/persimmons/juice/" + operation, + headers={"Origin": "https://persimmons.app"}) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {"error": "unauthorized"}) + self.assertEqual(response.headers["cache-control"], "no-store") + self.assertNotIn("access-control-allow-origin", response.headers) + requester.assert_not_called() + + async def test_authenticated_lifecycle_and_validation(self): + store = FakeGitea() + source = await create_connection(store.request, { + "provider": "Example", "credential_type": "Token", "credential": "hidden-source", + "vault_kind": "user", + }) + with patch.object(server, "persimmons_actor", AsyncMock(return_value="example-user")), patch.object(server, "persimmons_requester", return_value=store.request): + response = await self.client.post("/chat/persimmons/juice/create", json={ + "source_id": source["id"], "subject": "agent:example", "scopes": ["read"], + }) + self.assertEqual(response.status_code, 201) + juice = response.json() + listed = await self.client.get("/chat/persimmons/juice/list", params={"source_id": source["id"]}) + self.assertEqual(listed.json(), {"juice": [juice]}) + revoked = await self.client.post("/chat/persimmons/juice/revoke", json={"id": juice["id"]}) + self.assertEqual(revoked.status_code, 200) + self.assertEqual(revoked.json()["status"], "revoked") + self.assertNotIn("hidden-source", response.text + listed.text + revoked.text) + invalid = await self.client.post("/chat/persimmons/juice/create", json=[]) + self.assertEqual(invalid.status_code, 400) + + async def test_storage_errors_do_not_leak_details(self): + with patch.object(server, "persimmons_actor", AsyncMock(return_value="example-user")), patch.object(server, "persimmons_requester", return_value=AsyncMock(side_effect=RuntimeError("private detail"))): + response = await self.client.get("/chat/persimmons/juice/list") + self.assertEqual(response.status_code, 502) + self.assertNotIn("private detail", response.text) + + +class JuiceMcpTests(unittest.IsolatedAsyncioTestCase): + async def test_registered_agent_route_never_reads_or_returns_source_value(self): + store = FakeGitea() + sentinel = secrets.token_hex(24) + source = await create_connection(store.request, { + "provider": "Example", "credential_type": "Token", "credential": sentinel, + "vault_kind": "user", + }) + juice = await create_juice(store.request, { + "source_id": source["id"], "subject": "agent:example", "scopes": ["read"], + }) + key = "PERSIM_JUICE_" + juice["id"] + poisoned = json.loads(store.user_variables[key]) + poisoned["credential"] = sentinel + store.user_variables[key] = json.dumps(poisoned) + calls = [] + + async def requester(method, path, **kwargs): + calls.append((method, path)) + self.assertEqual(method, "GET") + self.assertNotIn("/secrets", path) + return await store.request(method, path, **kwargs) + + # Exercise the registered tool through MCP without mounting unrelated + # runtime proxies or starting the production application's background jobs. + isolated = FastMCP("Juice route test") + isolated.add_tool(await server.mcp.local_provider.get_tool("persimmons_juice_list")) + with patch.object(server, "actor_gitea_request", requester): + async with Client(isolated) as client: + result = await client.call_tool("persimmons_juice_list", {"source_id": source["id"]}) + self.assertFalse(result.is_error) + self.assertEqual(result.data, {"juice": [juice]}) + # Use a boolean assertion so a regression cannot echo the sentinel in logs. + self.assertTrue(sentinel not in str(result)) + self.assertTrue(calls)