Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions api/ee/tests/pytest/acceptance/tools/test_tools_connections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""EE acceptance tests for the /tools/connections contract (WP0).

Mirrors the OSS suite (oss/tests/pytest/acceptance/tools/test_tools_connections.py)
but exercises /tools/connections as a business-plan, developer-role account.
Under EE the endpoints are gated on the tools permission surface (VIEW_TOOLS for
reads, EDIT_TOOLS for writes); a developer role carries both, so this verifies
the contract behaves once the gate is satisfied.

The query endpoint is DB-only and needs no Composio credentials — it also proves
the gateway_connections rename landed in EE. Create / revoke make real provider
calls, so those are gated on COMPOSIO_API_KEY.

Requires a running API.
"""

import os
from uuid import uuid4

import pytest
import requests

from utils.constants import BASE_TIMEOUT


_COMPOSIO_ENABLED = bool(os.getenv("COMPOSIO_API_KEY"))
_requires_composio = pytest.mark.skipif(
not _COMPOSIO_ENABLED,
reason="needs live Composio credentials (COMPOSIO_API_KEY)",
)


def _create_developer_business_account(admin_api):
uid = uuid4().hex[:12]
email = f"connections-dev-{uid}@test.agenta.ai"
resp = admin_api(
"POST",
"/admin/simple/accounts/",
json={
"accounts": {
"u": {
"user": {"email": email},
"options": {
"create_api_keys": True,
"return_api_keys": True,
"seed_defaults": False,
},
"subscription": {"plan": "cloud_v0_business"},
"organization_memberships": [
{
"organization_ref": {"ref": "org"},
"user_ref": {"ref": "user"},
"role": "developer",
}
],
"workspace_memberships": [
{
"workspace_ref": {"ref": "wrk"},
"user_ref": {"ref": "user"},
"role": "developer",
}
],
"project_memberships": [
{
"project_ref": {"ref": "prj"},
"user_ref": {"ref": "user"},
"role": "developer",
}
],
}
}
},
)
assert resp.status_code == 200, resp.text
account = resp.json()["accounts"]["u"]
return {
"email": email,
"credentials": f"ApiKey {account['api_keys']['key']}",
}


def _delete_account_by_email(admin_api, *, email):
resp = admin_api(
"DELETE",
"/admin/simple/accounts/",
json={"accounts": {"u": {"user": {"email": email}}}, "confirm": "delete"},
)
assert resp.status_code == 204, resp.text


@pytest.fixture(scope="class")
def connections_api(admin_api, ag_env):
account = _create_developer_business_account(admin_api)

def _request(method: str, endpoint: str, **kwargs):
headers = kwargs.pop("headers", {})
headers.setdefault("Authorization", account["credentials"])
return requests.request(
method=method,
url=f"{ag_env['api_url']}{endpoint}",
headers=headers,
timeout=BASE_TIMEOUT,
**kwargs,
)

yield _request

_delete_account_by_email(admin_api, email=account["email"])


class TestToolsConnectionsQuery:
def test_query_connections_returns_200(self, connections_api):
response = connections_api("POST", "/tools/connections/query")
assert response.status_code == 200

def test_query_connections_response_shape(self, connections_api):
body = connections_api("POST", "/tools/connections/query").json()
assert "count" in body
assert "connections" in body
assert isinstance(body["connections"], list)
assert body["count"] == len(body["connections"])


class TestToolsConnectionsGet:
def test_get_unknown_connection_returns_404(self, connections_api):
response = connections_api("GET", f"/tools/connections/{uuid4()}")
assert response.status_code == 404


@_requires_composio
class TestToolsConnectionsLifecycle:
def test_create_revoke_roundtrip(self, connections_api):
slug = f"acc-{uuid4().hex[:8]}"
create = connections_api(
"POST",
"/tools/connections/",
json={
"connection": {
"slug": slug,
"provider_key": "composio",
"integration_key": "github",
"data": {"auth_scheme": "oauth"},
}
},
)
assert create.status_code == 200, create.text
connection_id = create.json()["connection"]["id"]

# Local-only revoke (C7/B3): flips is_valid on the shared row, no
# provider call, no cascade.
revoke = connections_api("POST", f"/tools/connections/{connection_id}/revoke")
assert revoke.status_code == 200, revoke.text
assert revoke.json()["connection"]["flags"]["is_valid"] is False

connections_api("DELETE", f"/tools/connections/{connection_id}")
Comment on lines +150 to +154
29 changes: 26 additions & 3 deletions api/entrypoints/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@

from oss.src.core.accounts.service import PlatformAdminAccountsService
from oss.src.apis.fastapi.accounts.router import PlatformAdminAccountsRouter
from oss.src.dbs.postgres.tools.dao import ToolsDAO
from oss.src.dbs.postgres.connections.dao import ConnectionsDAO
from oss.src.core.connections.providers.composio import ComposioConnectionsAdapter
from oss.src.core.connections.registry import ConnectionsGatewayRegistry
from oss.src.core.connections.service import ConnectionsService
from oss.src.core.tools.providers.composio import ComposioToolsAdapter
from oss.src.core.tools.registry import ToolsGatewayRegistry
from oss.src.core.tools.service import ToolsService
Expand Down Expand Up @@ -209,6 +212,9 @@ async def lifespan(*args, **kwargs):
for adapter in _composio_adapters.values():
await adapter.close()

for adapter in _composio_connections_adapters.values():
await adapter.close()

await _transactions_engine.close()
await _analytics_engine.close()
await _streams_engine.close()
Expand Down Expand Up @@ -439,7 +445,7 @@ async def lifespan(*args, **kwargs):
evaluations_dao = EvaluationsDAO(engine=_transactions_engine)
folders_dao = FoldersDAO(engine=_transactions_engine)

tools_dao = ToolsDAO(engine=_transactions_engine)
connections_dao = ConnectionsDAO(engine=_transactions_engine)

# SERVICES ---------------------------------------------------------------------

Expand Down Expand Up @@ -574,6 +580,23 @@ async def lifespan(*args, **kwargs):
simple_evaluations_service=simple_evaluations_service,
)

# Connections adapter + service (owns gateway_connections; consumed by tools)
_composio_connections_adapters = {}
if env.composio.enabled:
_composio_connections_adapters["composio"] = ComposioConnectionsAdapter(
api_key=env.composio.api_key, # type: ignore[arg-type] # guarded by .enabled
api_url=env.composio.api_url,
)

connections_adapter_registry = ConnectionsGatewayRegistry(
adapters=_composio_connections_adapters,
)

connections_service = ConnectionsService(
connections_dao=connections_dao,
adapter_registry=connections_adapter_registry,
)

# Tools adapter + service
_composio_adapters = {}
if env.composio.enabled:
Expand All @@ -589,7 +612,7 @@ async def lifespan(*args, **kwargs):
)

tools_service = ToolsService(
tools_dao=tools_dao,
connections_service=connections_service,
adapter_registry=tools_adapter_registry,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""rename tool_connections to gateway_connections

Connection ownership moves out of /tools into the shared, routerless
connections domain (gateway-triggers WP0). Rename-only — no data transform.
Authored once in the shared core_oss chain so it runs in BOTH editions; the
legacy chain that created tool_connections is parked.

Revision ID: oss000000002
Revises: oss000000001
Create Date: 2026-06-18 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op


# revision identifiers, used by Alembic.
revision: str = "oss000000002"
down_revision: Union[str, None] = "oss000000001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.rename_table("tool_connections", "gateway_connections")
op.execute(
"ALTER TABLE gateway_connections "
"RENAME CONSTRAINT uq_tool_connections_project_provider_integration_slug "
"TO uq_gateway_connections_project_provider_integration_slug"
)
op.execute(
"ALTER INDEX ix_tool_connections_project_provider_integration "
"RENAME TO ix_gateway_connections_project_provider_integration"
)


def downgrade() -> None:
op.execute(
"ALTER INDEX ix_gateway_connections_project_provider_integration "
"RENAME TO ix_tool_connections_project_provider_integration"
)
op.execute(
"ALTER TABLE gateway_connections "
"RENAME CONSTRAINT uq_gateway_connections_project_provider_integration_slug "
"TO uq_tool_connections_project_provider_integration_slug"
)
op.rename_table("gateway_connections", "tool_connections")
13 changes: 7 additions & 6 deletions api/oss/src/apis/fastapi/tools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from pydantic import BaseModel

from oss.src.core.connections.dtos import (
Connection,
ConnectionCreate,
)
from oss.src.core.tools.dtos import (
# Tool Catalog
ToolCatalogAction,
Expand All @@ -10,9 +14,6 @@
ToolCatalogIntegrationDetails,
ToolCatalogProvider,
ToolCatalogProviderDetails,
# Tool Connections
ToolConnection,
ToolConnectionCreate,
# Tool Calls
ToolResult,
)
Expand Down Expand Up @@ -67,17 +68,17 @@ class ToolCatalogActionsResponse(BaseModel):


class ToolConnectionCreateRequest(BaseModel):
connection: ToolConnectionCreate
connection: ConnectionCreate


class ToolConnectionResponse(BaseModel):
count: int = 0
connection: Optional[ToolConnection] = None
connection: Optional[Connection] = None


class ToolConnectionsResponse(BaseModel):
count: int = 0
connections: List[ToolConnection] = []
connections: List[Connection] = []


# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion api/oss/src/apis/fastapi/tools/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from oss.src.core.tools.service import (
ToolsService,
)
from oss.src.core.tools.utils import decode_oauth_state
from oss.src.core.connections.utils import decode_oauth_state
from oss.src.utils.env import env

_SLUG_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
Expand Down
Empty file.
Loading
Loading