Consolidate Persimmons shared backend and tests
This commit is contained in:
1 parent
f0b9d468f2
commit
2e92247d80
1 file changed
+102
@@ -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)
|
||||
Reference in new issue
Block a user