diff --git a/.github/workflows/live-integration-tests.yml b/.github/workflows/live-integration-tests.yml new file mode 100644 index 0000000..12ef889 --- /dev/null +++ b/.github/workflows/live-integration-tests.yml @@ -0,0 +1,154 @@ +name: Live Integration Tests + +on: + pull_request: + branches: [main] + paths: + - 'capiscio_mcp/**' + - 'tests/integration/**' + - 'docker-compose.test.yml' + - '.github/workflows/live-integration-tests.yml' + push: + branches: [main] + paths: + - 'capiscio_mcp/**' + - 'tests/integration/**' + workflow_dispatch: + repository_dispatch: + types: [run-e2e-tests] + +permissions: + contents: read + pull-requests: write + +jobs: + live-integration: + name: Live Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout MCP repository + uses: actions/checkout@v4 + + - name: Checkout capiscio-core + uses: actions/checkout@v4 + with: + repository: capiscio/capiscio-core + path: _capiscio-core + token: ${{ secrets.REPO_ACCESS_TOKEN }} + + - name: Checkout capiscio-server + uses: actions/checkout@v4 + with: + repository: capiscio/capiscio-server + path: _capiscio-server + token: ${{ secrets.REPO_ACCESS_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build capiscio-core binary + working-directory: _capiscio-core + run: | + go build -o bin/capiscio ./cmd/capiscio + chmod +x bin/capiscio + echo "CAPISCIO_BINARY_PATH=${{ github.workspace }}/_capiscio-core/bin/capiscio" >> $GITHUB_ENV + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev,mcp]" + + - name: Start PostgreSQL + run: | + docker run -d --name test-postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=capiscio \ + -p 5435:5432 \ + --health-cmd "pg_isready -U postgres" \ + --health-interval 5s \ + --health-timeout 5s \ + --health-retries 5 \ + postgres:15-alpine + + # Wait for PostgreSQL + for i in $(seq 1 30); do + if docker exec test-postgres pg_isready -U postgres; then + echo "PostgreSQL ready" + break + fi + sleep 1 + done + + - name: Build and start capiscio-server + working-directory: _capiscio-server + run: | + go build -tags cloud -o bin/capiscio-server ./cmd/server + chmod +x bin/capiscio-server + + export DATABASE_URL="postgres://postgres:postgres@localhost:5435/capiscio?sslmode=disable" + export PORT=8081 + export LOG_LEVEL=debug + export CA_ISSUER_URL="http://localhost:8081" + export ALLOW_AGENT_REGISTRATION=true + + ./bin/capiscio-server & + SERVER_PID=$! + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV + + # Wait for server health + for i in $(seq 1 30); do + if curl -sf http://localhost:8081/health > /dev/null 2>&1; then + echo "Server ready at :8081" + break + fi + sleep 1 + done + + - name: Start capiscio-core gRPC + run: | + export CAPISCIO_BINARY_PATH="${{ github.workspace }}/_capiscio-core/bin/capiscio" + $CAPISCIO_BINARY_PATH rpc --address localhost:50051 & + CORE_PID=$! + echo "CORE_PID=$CORE_PID" >> $GITHUB_ENV + echo "CAPISCIO_CORE_ADDR=localhost:50051" >> $GITHUB_ENV + + # Wait for gRPC health + sleep 3 + echo "Core gRPC started on :50051" + + - name: Run live integration tests + env: + CAPISCIO_SERVER_URL: http://localhost:8081 + CAPISCIO_CORE_ADDR: localhost:50051 + CAPISCIO_API_KEY: test-integration-key + run: | + pytest tests/integration/ -v --tb=short -m integration \ + --junitxml=integration-results.xml + + echo "Integration test run complete" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-test-results + path: integration-results.xml + + - name: Cleanup + if: always() + run: | + kill ${{ env.SERVER_PID }} 2>/dev/null || true + kill ${{ env.CORE_PID }} 2>/dev/null || true + docker stop test-postgres 2>/dev/null || true + docker rm test-postgres 2>/dev/null || true diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..efcf5fc --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + # PostgreSQL for capiscio-server + db: + image: postgres:15-alpine + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=capiscio + ports: + - "5435:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + # capiscio-server REST API + server: + build: + context: ../capiscio-server + dockerfile: Dockerfile + ports: + - "8081:8080" + environment: + - DATABASE_URL=postgres://postgres:postgres@db:5432/capiscio?sslmode=disable + - PORT=8080 + - LOG_LEVEL=debug + - CA_ISSUER_URL=http://server:8080 + - ALLOW_AGENT_REGISTRATION=true + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 5s + timeout: 5s + retries: 10 + + # MCP Python test runner + test-runner: + build: + context: . + dockerfile: Dockerfile.test + volumes: + - .:/workspace + working_dir: /workspace + environment: + - CAPISCIO_SERVER_URL=http://server:8080 + - CAPISCIO_CORE_ADDR= # empty = embedded mode (auto-download binary) + - CAPISCIO_API_KEY=test-integration-key + - PYTHONPATH=/workspace + - PYTEST_ARGS=${PYTEST_ARGS:--v} + depends_on: + server: + condition: service_healthy + command: > + pytest tests/integration/ -v --tb=short + -m integration ${PYTEST_ARGS} + +volumes: + db-data: diff --git a/pyproject.toml b/pyproject.toml index bb7b9ab..32731b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] +markers = [ + "integration: live integration tests requiring capiscio-core and/or capiscio-server", +] [tool.coverage.run] source = ["capiscio_mcp"] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..f85390b --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,52 @@ +"""Pytest fixtures for live integration tests. + +These tests require: +- capiscio-core gRPC server (via CAPISCIO_CORE_ADDR or embedded binary) +- capiscio-server REST API (via CAPISCIO_SERVER_URL or default localhost:8080) + +Skip automatically when infrastructure is unavailable. +""" + +import os +import time + +import pytest + +CAPISCIO_SERVER_URL = os.getenv("CAPISCIO_SERVER_URL", "http://localhost:8080") +CAPISCIO_API_KEY = os.getenv("CAPISCIO_API_KEY", "test-integration-key") + + +@pytest.fixture(scope="session") +def server_url(): + """Base URL for capiscio-server.""" + return CAPISCIO_SERVER_URL + + +@pytest.fixture(scope="session") +def api_key(): + """API key for capiscio-server SDK endpoints.""" + return CAPISCIO_API_KEY + + +@pytest.fixture(scope="session") +def wait_for_server(): + """Wait for capiscio-server to be healthy (up to 30s).""" + import requests + + for i in range(30): + try: + resp = requests.get(f"{CAPISCIO_SERVER_URL}/health", timeout=2) + if resp.status_code == 200: + return True + except requests.exceptions.RequestException: + pass + time.sleep(1) + pytest.skip(f"Server not available at {CAPISCIO_SERVER_URL} after 30s") + + +@pytest.fixture(autouse=True) +async def _reset_core_client(): + """Reset CoreClient singleton between tests to avoid stale connections.""" + yield + from capiscio_mcp._core.client import CoreClient + await CoreClient.reset() diff --git a/tests/integration/test_connect_live.py b/tests/integration/test_connect_live.py new file mode 100644 index 0000000..914823d --- /dev/null +++ b/tests/integration/test_connect_live.py @@ -0,0 +1,158 @@ +""" +Live integration tests for MCPServerIdentity.connect(). + +Tests the full "Let's Encrypt"-style server identity flow: + connect() → generate keypair (gRPC) → register (REST) → issue badge → keeper + +Requires: +- capiscio-core gRPC server (for key generation) +- capiscio-server REST API (for registration + badge issuance) +""" + +import os +import tempfile + +import pytest +import requests + +from capiscio_mcp.connect import MCPServerIdentity +from capiscio_mcp.errors import CoreConnectionError + +_server_url = os.getenv("CAPISCIO_SERVER_URL", "http://localhost:8080") + +def _infra_available(): + core = bool( + os.environ.get("CAPISCIO_CORE_ADDR") + or os.environ.get("CAPISCIO_BINARY_PATH") + ) + try: + resp = requests.get(f"{_server_url}/health", timeout=2) + server = resp.status_code == 200 + except requests.exceptions.RequestException: + server = False + return core and server + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not _infra_available(), + reason="Both capiscio-server and capiscio-core required", + ), +] + + +class TestMCPServerIdentityConnect: + """Test MCPServerIdentity.connect() against live infrastructure.""" + + async def test_connect_generates_keypair(self, server_url, api_key): + """connect() should generate an Ed25519 keypair via gRPC.""" + with tempfile.TemporaryDirectory() as keys_dir: + try: + identity = await MCPServerIdentity.connect( + server_id="test-integration-connect", + api_key=api_key, + server_url=server_url, + keys_dir=keys_dir, + auto_badge=False, + ) + + assert os.path.exists(os.path.join(keys_dir, "private_key.pem")) + assert os.path.exists(os.path.join(keys_dir, "public_key.pem")) + assert os.path.exists(os.path.join(keys_dir, "did.txt")) + assert identity.did is not None + assert identity.did.startswith("did:") + + identity.close() + except CoreConnectionError: + pytest.skip("Core connection failed — binary may not support this flow") + + async def test_connect_full_flow(self, server_url, api_key): + """Full connect flow: keygen → register → badge → keeper.""" + with tempfile.TemporaryDirectory() as keys_dir: + try: + identity = await MCPServerIdentity.connect( + server_id="test-integration-full", + api_key=api_key, + server_url=server_url, + keys_dir=keys_dir, + auto_badge=True, + ) + + badge = identity.get_badge() + assert badge is not None + assert isinstance(badge, str) + assert len(badge.split(".")) == 3 # JWS compact + + assert identity.did is not None + + identity.close() + except CoreConnectionError: + pytest.skip("Core connection failed") + except Exception as e: + if "401" in str(e) or "403" in str(e): + pytest.skip(f"Auth error (expected in CI without valid key): {e}") + raise + + async def test_connect_idempotent_registration(self, server_url, api_key): + """Calling connect() twice with same server_id should be idempotent.""" + with tempfile.TemporaryDirectory() as keys_dir: + try: + identity1 = await MCPServerIdentity.connect( + server_id="test-integration-idempotent", + api_key=api_key, + server_url=server_url, + keys_dir=keys_dir, + auto_badge=False, + ) + did1 = identity1.did + identity1.close() + + identity2 = await MCPServerIdentity.connect( + server_id="test-integration-idempotent", + api_key=api_key, + server_url=server_url, + keys_dir=keys_dir, + auto_badge=False, + ) + did2 = identity2.did + identity2.close() + + assert did1 == did2 + except CoreConnectionError: + pytest.skip("Core connection failed") + except Exception as e: + if "401" in str(e) or "403" in str(e): + pytest.skip(f"Auth error: {e}") + raise + + async def test_connect_context_manager(self, server_url, api_key): + """MCPServerIdentity should work as a context manager.""" + with tempfile.TemporaryDirectory() as keys_dir: + try: + identity = await MCPServerIdentity.connect( + server_id="test-integration-ctx", + api_key=api_key, + server_url=server_url, + keys_dir=keys_dir, + auto_badge=False, + ) + with identity: + assert identity.did is not None + except CoreConnectionError: + pytest.skip("Core connection failed") + except Exception as e: + if "401" in str(e) or "403" in str(e): + pytest.skip(f"Auth error: {e}") + raise + + async def test_connect_invalid_api_key_rejected(self, server_url): + """Invalid API key should be rejected by the server.""" + with tempfile.TemporaryDirectory() as keys_dir: + with pytest.raises(Exception): + await MCPServerIdentity.connect( + server_id="test-integration-badkey", + api_key="invalid-key-that-should-fail", + server_url=server_url, + keys_dir=keys_dir, + auto_badge=True, + ) diff --git a/tests/integration/test_guard_live.py b/tests/integration/test_guard_live.py new file mode 100644 index 0000000..e8dda61 --- /dev/null +++ b/tests/integration/test_guard_live.py @@ -0,0 +1,141 @@ +""" +Live integration tests for guard() and evaluate_tool_access(). + +Tests the full guard path: Python → gRPC → capiscio-core → policy evaluation. +Requires a running capiscio-core (embedded binary or external via CAPISCIO_CORE_ADDR). +""" + +import os + +import pytest + +from capiscio_mcp.guard import evaluate_tool_access, GuardConfig, GuardResult +from capiscio_mcp.types import Decision, CallerCredential, AuthLevel + +_core_available = bool( + os.environ.get("CAPISCIO_CORE_ADDR") + or os.environ.get("CAPISCIO_BINARY_PATH") +) + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not _core_available, + reason="CAPISCIO_CORE_ADDR or CAPISCIO_BINARY_PATH not set", + ), +] + + +class TestEvaluateToolAccessLive: + """Test evaluate_tool_access against a live capiscio-core gRPC server.""" + + async def test_anonymous_caller_denied(self): + """Anonymous caller (no credential) should be denied by default.""" + result = await evaluate_tool_access( + tool_name="read_file", + params={"path": "/etc/passwd"}, + credential=CallerCredential(), # anonymous + config=GuardConfig(min_trust_level=1), + ) + + assert isinstance(result, GuardResult) + assert result.decision == Decision.DENY + assert result.auth_level == AuthLevel.ANONYMOUS + + async def test_api_key_credential(self): + """API key credential should reach core and get a decision.""" + result = await evaluate_tool_access( + tool_name="list_files", + params={"directory": "/tmp"}, + credential=CallerCredential(api_key="test-key-12345"), + config=GuardConfig(min_trust_level=0, accept_level_zero=True), + ) + + assert isinstance(result, GuardResult) + assert result.decision in (Decision.ALLOW, Decision.DENY) + + async def test_params_hash_computed(self): + """Params hash should be computed (PII never sent to core).""" + from capiscio_mcp.guard import compute_params_hash + + params = {"query": "sensitive-data", "user_id": "12345"} + params_hash = compute_params_hash(params) + + assert params_hash.startswith("sha256:") + assert len(params_hash) > 10 + + # Same params → same hash (deterministic) + assert compute_params_hash(params) == params_hash + + # Different params → different hash + different = compute_params_hash({"query": "other"}) + assert different != params_hash + + async def test_guard_result_fields_populated(self): + """GuardResult should have all fields populated from core response.""" + result = await evaluate_tool_access( + tool_name="write_file", + params={"path": "/tmp/test.txt", "content": "hello"}, + credential=CallerCredential(api_key="test-key"), + config=GuardConfig(min_trust_level=1), + ) + + assert isinstance(result, GuardResult) + assert result.decision in (Decision.ALLOW, Decision.DENY) + if result.decision == Decision.DENY: + assert result.deny_reason is not None + + async def test_trusted_issuers_filter(self): + """Trusted issuers config should be forwarded to core.""" + result = await evaluate_tool_access( + tool_name="read_file", + params={}, + credential=CallerCredential(api_key="test-key"), + config=GuardConfig( + trusted_issuers=["did:web:registry.capisc.io"], + min_trust_level=2, + ), + ) + + assert isinstance(result, GuardResult) + + async def test_allowed_tools_restriction(self): + """Allowed tools config should restrict which tools are accessible.""" + result = await evaluate_tool_access( + tool_name="dangerous_tool", + params={}, + credential=CallerCredential(api_key="test-key"), + config=GuardConfig( + min_trust_level=0, + accept_level_zero=True, + allowed_tools=["safe_tool", "read_file"], + ), + ) + + assert isinstance(result, GuardResult) + assert result.decision == Decision.DENY + + +class TestDecisionCacheLive: + """Test the decision cache with live core responses.""" + + async def test_cache_hit_returns_same_result(self): + """Same credential + tool should return cached result.""" + credential = CallerCredential(api_key="cache-test-key") + config = GuardConfig(min_trust_level=0, accept_level_zero=True) + + result1 = await evaluate_tool_access( + tool_name="cached_tool", + params={"x": 1}, + credential=credential, + config=config, + ) + + result2 = await evaluate_tool_access( + tool_name="cached_tool", + params={"x": 1}, + credential=credential, + config=config, + ) + + assert result1.decision == result2.decision diff --git a/tests/integration/test_registration_live.py b/tests/integration/test_registration_live.py new file mode 100644 index 0000000..33069fe --- /dev/null +++ b/tests/integration/test_registration_live.py @@ -0,0 +1,164 @@ +""" +Live integration tests for server identity registration. + +Tests the registration flow: + generate_server_keypair (gRPC) → register_server_identity (REST PUT) + +Requires: +- capiscio-core gRPC server (for Ed25519 key generation) +- capiscio-server REST API (for identity registration) +""" + +import os +import uuid + +import pytest +import requests + +from capiscio_mcp.registration import ( + generate_server_keypair, + register_server_identity, + setup_server_identity, + RegistrationError, +) + +_server_url = os.getenv("CAPISCIO_SERVER_URL", "http://localhost:8080") + +_core_available = bool( + os.environ.get("CAPISCIO_CORE_ADDR") + or os.environ.get("CAPISCIO_BINARY_PATH") +) + +def _server_available(): + try: + resp = requests.get(f"{_server_url}/health", timeout=2) + return resp.status_code == 200 + except requests.exceptions.RequestException: + return False + +pytestmark = pytest.mark.integration + + +@pytest.mark.skipif(not _core_available, reason="capiscio-core not available") +class TestGenerateServerKeypairLive: + """Test Ed25519 key generation via gRPC to capiscio-core.""" + + async def test_generate_keypair_returns_keys(self): + """generate_server_keypair should return PEM key pair from core.""" + try: + result = await generate_server_keypair() + except Exception as e: + pytest.skip(f"Core keygen not available: {e}") + + assert "private_key_pem" in result or "private_key" in result + assert "public_key_pem" in result or "public_key" in result + + async def test_generate_keypair_unique(self): + """Each call should generate a unique keypair.""" + try: + result1 = await generate_server_keypair() + result2 = await generate_server_keypair() + except Exception as e: + pytest.skip(f"Core keygen not available: {e}") + + key1 = result1.get("public_key_pem") or result1.get("public_key") + key2 = result2.get("public_key_pem") or result2.get("public_key") + assert key1 != key2 + + +@pytest.mark.skipif(not _server_available(), reason=f"capiscio-server not available at {_server_url}") +class TestRegisterServerIdentityLive: + """Test server registration against live capiscio-server.""" + + async def test_register_new_server(self, server_url, api_key): + """Registering a new server should succeed or auto-create.""" + server_id = f"test-reg-{uuid.uuid4().hex[:8]}" + test_did = f"did:key:z6Mk{uuid.uuid4().hex[:32]}" + test_pubkey = "MCowBQYDK2VwAyEA" + "A" * 43 + "=" + + try: + result = await register_server_identity( + server_id=server_id, + api_key=api_key, + did=test_did, + public_key=test_pubkey, + ca_url=server_url, + ) + except Exception as e: + if "401" in str(e) or "403" in str(e): + pytest.skip(f"Auth error (expected without valid key): {e}") + raise + + assert result.get("success") is True or result.get("created") is True + + async def test_register_idempotent(self, server_url, api_key): + """Registering the same server_id twice should be idempotent (409 = OK).""" + server_id = f"test-idempotent-{uuid.uuid4().hex[:8]}" + test_did = f"did:key:z6Mk{uuid.uuid4().hex[:32]}" + test_pubkey = "MCowBQYDK2VwAyEA" + "B" * 43 + "=" + + try: + await register_server_identity( + server_id=server_id, + api_key=api_key, + did=test_did, + public_key=test_pubkey, + ca_url=server_url, + ) + result = await register_server_identity( + server_id=server_id, + api_key=api_key, + did=test_did, + public_key=test_pubkey, + ca_url=server_url, + ) + except Exception as e: + if "401" in str(e) or "403" in str(e): + pytest.skip(f"Auth error: {e}") + if "409" not in str(e): + raise + # 409 Conflict is expected for idempotent re-registration + return + + # If the second call succeeded without error, it should still report success + assert result is not None + + async def test_register_missing_api_key_rejected(self, server_url): + """Registration without a valid API key should fail.""" + server_id = f"test-nokey-{uuid.uuid4().hex[:8]}" + + with pytest.raises(Exception): + await register_server_identity( + server_id=server_id, + api_key="", + did="did:key:z6MkInvalid", + public_key="invalid", + ca_url=server_url, + ) + + +@pytest.mark.skipif( + not (_core_available and _server_available()), + reason="Both capiscio-server and capiscio-core required", +) +class TestSetupServerIdentityLive: + """Test end-to-end setup_server_identity (keygen + registration).""" + + async def test_setup_generates_and_registers(self, server_url, api_key): + """setup_server_identity should do keygen + registration in one call.""" + server_id = f"test-setup-{uuid.uuid4().hex[:8]}" + + try: + result = await setup_server_identity( + server_id=server_id, + api_key=api_key, + ca_url=server_url, + ) + except (ConnectionError, TimeoutError, OSError) as e: + pytest.skip(f"Setup not available: {e}") + except Exception as e: + if "401" in str(e) or "403" in str(e): + pytest.skip(f"Auth error: {e}") + raise + + assert result is not None