diff --git a/e2e-tests/.env-example b/e2e-tests/.env-example new file mode 100644 index 000000000..5be229c16 --- /dev/null +++ b/e2e-tests/.env-example @@ -0,0 +1,41 @@ +# OPAL Test Environment Configuration + +# Server Configuration +SERVER_PORT=7002 +SERVER_HOST=0.0.0.0 +SERVER_WORKERS=4 +SERVER_LOG_LEVEL=DEBUG +SERVER_MASTER_TOKEN=master-token-for-testing + +# Client Configuration +CLIENT_PORT=7000 +CLIENT_HOST=0.0.0.0 +CLIENT_TOKEN=default-token-for-testing +CLIENT_LOG_LEVEL=DEBUG + +# Database Configuration +POSTGRES_PORT=5432 +POSTGRES_HOST=broadcast_channel +POSTGRES_DB=postgres +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +# Policy Configuration +POLICY_REPO_URL=https://github.com/permitio/opal-example-policy-repo +POLICY_REPO_POLLING_INTERVAL=30 + +# Network Configuration +NETWORK_NAME=opal_test_network + +# Authentication Configuration +AUTH_JWT_AUDIENCE=https://api.opal.ac/v1/ +AUTH_JWT_ISSUER=https://opal.ac/ + +# Test Configuration +TEST_TIMEOUT=300 +TEST_RETRY_INTERVAL=2 +TEST_MAX_RETRIES=30 + +# Statistics Configuration +STATISTICS_ENABLED=false +STATISTICS_CHECK_TIMEOUT=10 \ No newline at end of file diff --git a/e2e-tests/config.py b/e2e-tests/config.py new file mode 100644 index 000000000..6fc3ef0b7 --- /dev/null +++ b/e2e-tests/config.py @@ -0,0 +1,83 @@ +from pydantic import BaseSettings, Field +from typing import Optional +from pathlib import Path +import os + +class OPALEnvironment(BaseSettings): + """Environment configuration for OPAL tests with support for .env file""" + # Server Configuration + SERVER_PORT: int = Field(7002, description="OPAL server port") + SERVER_HOST: str = Field("0.0.0.0", description="OPAL server host") + SERVER_WORKERS: int = Field(4, description="Number of server workers") + SERVER_LOG_LEVEL: str = Field("DEBUG", description="Server log level") + SERVER_MASTER_TOKEN: str = Field("master-token-for-testing", description="Server master token") + + # Client Configuration + CLIENT_PORT: int = Field(7000, description="OPAL client port") + CLIENT_HOST: str = Field("0.0.0.0", description="OPAL client host") + CLIENT_TOKEN: str = Field("default-token-for-testing", description="Client auth token") + CLIENT_LOG_LEVEL: str = Field("DEBUG", description="Client log level") + + # Database Configuration + POSTGRES_PORT: int = Field(5432, description="PostgreSQL port") + POSTGRES_HOST: str = Field("broadcast_channel", description="PostgreSQL host") + POSTGRES_DB: str = Field("postgres", description="PostgreSQL database") + POSTGRES_USER: str = Field("postgres", description="PostgreSQL user") + POSTGRES_PASSWORD: str = Field("postgres", description="PostgreSQL password") + + + # Statistics Configuration + STATISTICS_ENABLED: bool = Field(True, description="Enable statistics collection") + STATISTICS_CHECK_TIMEOUT: int = Field(10, description="Timeout for statistics checks in seconds") + + # Policy Configuration + POLICY_REPO_URL: str = Field( + "https://github.com/permitio/opal-example-policy-repo", + description="Git repository URL for policies" + ) + POLICY_REPO_POLLING_INTERVAL: int = Field(30, description="Policy repo polling interval in seconds") + + # Network Configuration + NETWORK_NAME: str = Field("opal_test_network", description="Docker network name") + + # Authentication Configuration + AUTH_JWT_AUDIENCE: str = Field("https://api.opal.ac/v1/", description="JWT audience") + AUTH_JWT_ISSUER: str = Field("https://opal.ac/", description="JWT issuer") + + # Test Configuration + TEST_TIMEOUT: int = Field(300, description="Test timeout in seconds") + TEST_RETRY_INTERVAL: int = Field(2, description="Retry interval in seconds") + TEST_MAX_RETRIES: int = Field(30, description="Maximum number of retries") + + class Config: + env_file = '.env' + env_file_encoding = 'utf-8' + case_sensitive = True + + @property + def postgres_dsn(self) -> str: + """Get PostgreSQL connection string""" + return f"postgres://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + + @classmethod + def load_from_env_file(cls, env_file: str = '.env') -> 'OPALEnvironment': + """Load configuration from specific env file""" + if not os.path.exists(env_file): + raise FileNotFoundError(f"Environment file not found: {env_file}") + + return cls(_env_file=env_file) + +def get_environment() -> OPALEnvironment: + """Get environment configuration, with support for local development overrides""" + # Try local dev config first + local_env = Path('.env.local') + if local_env.exists(): + return OPALEnvironment.load_from_env_file('.env.local') + + # Fallback to default .env + default_env = Path('.env') + if default_env.exists(): + return OPALEnvironment.load_from_env_file('.env') + + # Use defaults/environment variables + return OPALEnvironment() \ No newline at end of file diff --git a/e2e-tests/container_config.py b/e2e-tests/container_config.py new file mode 100644 index 000000000..3db0cbe7d --- /dev/null +++ b/e2e-tests/container_config.py @@ -0,0 +1,75 @@ +"""Container configuration helpers""" +import json +from testcontainers.core.container import DockerContainer +from config import OPALEnvironment + +def configure_postgres(container: DockerContainer, config: OPALEnvironment): + """Configure Postgres container""" + container.with_env("POSTGRES_DB", config.POSTGRES_DB) + container.with_env("POSTGRES_USER", config.POSTGRES_USER) + container.with_env("POSTGRES_PASSWORD", config.POSTGRES_PASSWORD) + container.with_exposed_ports(config.POSTGRES_PORT) + container.with_kwargs(network=config.NETWORK_NAME) + +def configure_server(container: DockerContainer, config: OPALEnvironment): + """Configure OPAL server container with all required environment variables""" + env_vars = { + "PORT": str(config.SERVER_PORT), + "HOST": config.SERVER_HOST, + "UVICORN_NUM_WORKERS": str(config.SERVER_WORKERS), + "LOG_LEVEL": config.SERVER_LOG_LEVEL, + "OPAL_STATISTICS_ENABLED": "true", + "OPAL_BROADCAST_URI": config.postgres_dsn, + "BROADCAST_CHANNEL_NAME": "opal_updates", + "OPAL_POLICY_REPO_URL": config.POLICY_REPO_URL, + "OPAL_POLICY_REPO_POLLING_INTERVAL": str(config.POLICY_REPO_POLLING_INTERVAL), + "OPAL_DATA_CONFIG_SOURCES": json.dumps({ + "config": { + "entries": [{ + "url": "http://opal_server:7002/policy-data", + "topics": ["policy_data"], + "dst_path": "/static" + }] + } + }), + "AUTH_JWT_AUDIENCE": config.AUTH_JWT_AUDIENCE, + "AUTH_JWT_ISSUER": config.AUTH_JWT_ISSUER, + "AUTH_MASTER_TOKEN": config.SERVER_MASTER_TOKEN, + "OPAL_LOG_FORMAT_INCLUDE_PID": "true", + "LOG_FORMAT": "text", + "LOG_TRACEBACK": "true" + } + + for key, value in env_vars.items(): + container.with_env(key, value) + + container.with_exposed_ports(config.SERVER_PORT) + container.with_kwargs(network=config.NETWORK_NAME) + +def configure_client(container: DockerContainer, config: OPALEnvironment): + """Configure OPAL client container with all required environment variables""" + env_vars = { + "OPAL_SERVER_URL": f"http://opal_server:{config.SERVER_PORT}", + "PORT": str(config.CLIENT_PORT), + "HOST": config.CLIENT_HOST, + "LOG_LEVEL": config.CLIENT_LOG_LEVEL, + "OPAL_CLIENT_TOKEN": config.CLIENT_TOKEN, + "AUTH_JWT_AUDIENCE": config.AUTH_JWT_AUDIENCE, + "AUTH_JWT_ISSUER": config.AUTH_JWT_ISSUER, + "POLICY_UPDATER_ENABLED": "true", + "DATA_UPDATER_ENABLED": "true", + "INLINE_OPA_ENABLED": "true", + "OPA_HEALTH_CHECK_POLICY_ENABLED": "true", + "OPAL_INLINE_OPA_LOG_FORMAT": "http", + "INLINE_OPA_CONFIG": "{}", + "OPAL_STATISTICS_ENABLED": "true", + "OPAL_LOG_FORMAT_INCLUDE_PID": "true", + "LOG_FORMAT": "text", + "LOG_TRACEBACK": "true" + } + + for key, value in env_vars.items(): + container.with_env(key, value) + + container.with_exposed_ports(config.CLIENT_PORT, 8181) + container.with_kwargs(network=config.NETWORK_NAME) \ No newline at end of file diff --git a/e2e-tests/pytest.ini b/e2e-tests/pytest.ini new file mode 100644 index 000000000..786e18bd5 --- /dev/null +++ b/e2e-tests/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +asyncio_mode = strict +# Set the default fixture loop scope to function +asyncio_default_fixture_loop_scope = function + +# Optional but recommended settings +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) +log_cli_date_format = %Y-%m-%d %H:%M:%S \ No newline at end of file diff --git a/e2e-tests/test_cases.py b/e2e-tests/test_cases.py new file mode 100644 index 000000000..8ff740154 --- /dev/null +++ b/e2e-tests/test_cases.py @@ -0,0 +1,76 @@ +"""Test cases for OPAL integration tests""" +import logging +import json +import time +import pytest +import requests +from config import OPALEnvironment, get_environment +from test_environment import OPALTestEnvironment + +logger = logging.getLogger(__name__) + +@pytest.fixture(scope="module") +def opal_config(): + """Fixture that provides environment configuration""" + return get_environment() + +@pytest.fixture(scope="module") +def opal_env(opal_config): + """Main fixture that provides the test environment""" + env = OPALTestEnvironment(opal_config) + env.setup() + yield env + env.teardown() + +def test_opal_baseline(opal_env, opal_config): + """Test basic OPAL functionality""" + logger.info("Starting OPAL Environment Tests") + + time.sleep(opal_config.TEST_RETRY_INTERVAL * 5) # Allow services to stabilize + + # Test server health + logger.info("Testing Server Health") + server_url = f"http://{opal_env.containers['server'].get_container_host_ip()}:{opal_env.containers['server'].get_exposed_port(opal_config.SERVER_PORT)}" + server_health = requests.get(f"{server_url}/healthcheck") + assert server_health.status_code == 200, "Server health check failed" + logger.info("Server health check passed") + + # Test client health + logger.info("Testing Client Health") + client_url = f"http://{opal_env.containers['client'].get_container_host_ip()}:{opal_env.containers['client'].get_exposed_port(opal_config.CLIENT_PORT)}" + client_health = requests.get(f"{client_url}/healthcheck") + assert client_health.status_code == 200, "Client health check failed" + logger.info("Client health check passed") + + # Test OPA endpoint + logger.info("Testing OPA Health") + opa_url = f"http://{opal_env.containers['client'].get_container_host_ip()}:{opal_env.containers['client'].get_exposed_port(8181)}" + opa_health = requests.get(f"{opa_url}/health") + assert opa_health.status_code == 200, "OPA health check failed" + logger.info("OPA health check passed") + + # Check server statistics + logger.info("Checking Server Statistics") + stats_url = f"{server_url}/statistics" + headers = {"Authorization": f"Bearer {opal_config.SERVER_MASTER_TOKEN}"} + stats_response = requests.get(stats_url, headers=headers) + assert stats_response.status_code == 200, f"Failed to get statistics: HTTP {stats_response.status_code}" + stats_data = stats_response.json() + logger.info("Server Statistics: %s", json.dumps(stats_data, indent=2)) + + # Check for errors in logs + logger.info("Checking Container Logs") + for name, container in opal_env.containers.items(): + logs = container.get_logs()[0].decode() + error_count = sum(1 for line in logs.split('\n') if "ERROR" in line) + critical_count = sum(1 for line in logs.split('\n') if "CRITICAL" in line) + + if error_count > 0 or critical_count > 0: + logger.error(f"Found errors in {name} logs:") + logger.error(f"- {error_count} ERRORs") + logger.error(f"- {critical_count} CRITICALs") + assert False, f"Found {error_count + critical_count} errors in {name} logs" + else: + logger.info(f"{name}: No errors found in logs") + + logger.info("All basic health checks completed successfully") \ No newline at end of file diff --git a/e2e-tests/test_environment.py b/e2e-tests/test_environment.py new file mode 100644 index 000000000..8de9c1866 --- /dev/null +++ b/e2e-tests/test_environment.py @@ -0,0 +1,167 @@ +"""Test environment management""" +import logging +import time +from typing import Dict +import json +import docker +import requests +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs +from config import OPALEnvironment +from container_config import ( + configure_postgres, + configure_server, + configure_client +) + +# Configure logging +logger = logging.getLogger(__name__) + +class OPALTestEnvironment: + """Main test environment manager""" + def __init__(self, config: OPALEnvironment): + self.config = config + self.containers: Dict[str, DockerContainer] = {} + self.docker_client = docker.from_env() + self.network = None + self.network_id = None + + def setup(self): + """Setup the complete test environment""" + try: + self._create_network() + logger.info(f"Created network: {self.config.NETWORK_NAME}") + + # Start Postgres broadcast channel + logger.info("Starting Postgres Container") + self.containers['postgres'] = DockerContainer("postgres:alpine") + configure_postgres(self.containers['postgres'], self.config) + self.containers['postgres'].with_name("broadcast_channel") + self.containers['postgres'].start() + + # Wait for Postgres + self._wait_for_postgres() + + # Start OPAL Server + logger.info("Starting OPAL Server Container") + self.containers['server'] = DockerContainer("permitio/opal-server:latest") + configure_server(self.containers['server'], self.config) + self.containers['server'].with_name("opal_server") + self.containers['server'].start() + + # Wait for server + self._wait_for_server() + + # Start OPAL Client + logger.info("Starting OPAL Client Container") + self.containers['client'] = DockerContainer("permitio/opal-client:latest") + configure_client(self.containers['client'], self.config) + self.containers['client'].with_name("opal_client") + self.containers['client'].with_command( + f"sh -c 'exec ./wait-for.sh opal_server:{self.config.SERVER_PORT} --timeout=20 -- ./start.sh'" + ) + self.containers['client'].start() + + # Wait for client + self._wait_for_client() + + except Exception as e: + logger.error(f"Error during setup: {str(e)}") + self._log_container_status() + self.teardown() + raise + + def _create_network(self): + """Create Docker network for test environment""" + try: + # Remove network if it exists + try: + existing_network = self.docker_client.networks.get(self.config.NETWORK_NAME) + existing_network.remove() + except docker.errors.NotFound: + pass + + # Create new network + self.network = self.docker_client.networks.create( + name=self.config.NETWORK_NAME, + driver="bridge", + check_duplicate=True + ) + self.network_id = self.network.id + except Exception as e: + raise Exception(f"Failed to create network: {str(e)}") + + def _wait_for_postgres(self): + """Wait for Postgres to be ready""" + logger.info("Waiting for Postgres to be ready...") + wait_for_logs(self.containers['postgres'], "database system is ready to accept connections", timeout=30) + logger.info("Postgres is ready") + time.sleep(2) + + def _wait_for_server(self): + """Wait for server to be ready with retries""" + logger.info("Waiting for OPAL server to be ready...") + + for retry in range(self.config.TEST_MAX_RETRIES): + try: + server_url = f"http://{self.containers['server'].get_container_host_ip()}:{self.containers['server'].get_exposed_port(self.config.SERVER_PORT)}" + response = requests.get(f"{server_url}/healthcheck", timeout=5) + if response.status_code == 200: + logger.info("OPAL server is ready") + time.sleep(5) # Allow stabilization + return + except Exception as e: + logger.warning(f"Server not ready (attempt {retry + 1}/{self.config.TEST_MAX_RETRIES}): {str(e)}") + if retry == self.config.TEST_MAX_RETRIES - 1: + self._log_container_status() + raise TimeoutError("OPAL server failed to become healthy") + time.sleep(self.config.TEST_RETRY_INTERVAL) + + def _wait_for_client(self): + """Wait for client to be ready with retries""" + logger.info("Waiting for OPAL client to be ready...") + + for retry in range(self.config.TEST_MAX_RETRIES): + try: + client_url = f"http://{self.containers['client'].get_container_host_ip()}:{self.containers['client'].get_exposed_port(self.config.CLIENT_PORT)}" + response = requests.get(f"{client_url}/healthcheck", timeout=5) + if response.status_code == 200: + logger.info("OPAL client is ready") + time.sleep(5) # Allow OPA initialization + return + except Exception as e: + logger.warning(f"Client not ready (attempt {retry + 1}/{self.config.TEST_MAX_RETRIES}): {str(e)}") + if retry == self.config.TEST_MAX_RETRIES - 1: + self._log_container_status() + raise TimeoutError("OPAL client failed to become healthy") + time.sleep(self.config.TEST_RETRY_INTERVAL) + + def _log_container_status(self): + """Log container statuses and logs for debugging""" + logger.debug("=== Container Status ===") + for name, container in self.containers.items(): + try: + logger.debug(f"=== {name.upper()} LOGS ===") + logger.debug("STDOUT:") + logger.debug(container.get_logs()[0].decode()) + logger.debug("STDERR:") + logger.debug(container.get_logs()[1].decode()) + except Exception as e: + logger.error(f"Could not get logs for {name}: {str(e)}") + + def teardown(self): + """Cleanup all resources""" + logger.info("Cleaning up test environment") + for name, container in reversed(list(self.containers.items())): + try: + container.stop() + logger.info(f"Stopped container: {name}") + except Exception as e: + logger.error(f"Error stopping container {name}: {str(e)}") + + if self.network: + try: + self.network.remove() + logger.info(f"Removed network: {self.config.NETWORK_NAME}") + except Exception as e: + logger.error(f"Error removing network: {str(e)}") \ No newline at end of file diff --git a/e2e-tests/test_validation.py b/e2e-tests/test_validation.py new file mode 100644 index 000000000..747dac7a1 --- /dev/null +++ b/e2e-tests/test_validation.py @@ -0,0 +1,99 @@ +"""Validation utilities""" +import logging +import json +import requests +from config import OPALEnvironment +from test_environment import OPALTestEnvironment + +logger = logging.getLogger(__name__) + +def validate_statistics(stats: dict) -> bool: + """Validate statistics data structure and content""" + required_fields = ["clients", "uptime", "version"] + for field in required_fields: + if field not in stats: + logger.error(f"Missing required field in statistics: {field}") + return False + + if not stats["clients"]: + logger.error("No clients found in statistics") + return False + + # Verify client subscriptions + found_client = False + expected_topics = ["policy_data"] + + for client_id, client_data in stats["clients"].items(): + logger.info(f"Client {client_id} Data: {json.dumps(client_data, indent=2)}") + + if isinstance(client_data, list): + for conn in client_data: + client_topics = conn.get("topics", []) + if any(topic in client_topics for topic in expected_topics): + found_client = True + logger.info(f"Found client with expected topics: {client_topics}") + break + + if not found_client: + logger.error("No client found with expected topic subscriptions") + return False + + return True + +def check_client_server_connection(env: OPALTestEnvironment) -> bool: + """Verify client-server connection using Statistics API""" + try: + server_url = f"http://{env.containers['server'].get_container_host_ip()}:{env.containers['server'].get_exposed_port(env.config.SERVER_PORT)}" + stats_url = f"{server_url}/statistics" + headers = {"Authorization": f"Bearer {env.config.SERVER_MASTER_TOKEN}"} + + logger.info(f"Checking statistics at: {stats_url}") + response = requests.get(stats_url, headers=headers) + + if response.status_code != 200: + logger.error(f"Failed to get statistics: HTTP {response.status_code}") + logger.error(f"Response: {response.text}") + return False + + stats = response.json() + logger.info("Server Statistics: %s", json.dumps(stats, indent=2)) + + if not validate_statistics(stats): + return False + + logger.info("Client-server connection verified successfully") + return True + + except Exception as e: + logger.error(f"Error checking client-server connection: {str(e)}") + return False + +def check_container_logs_for_errors(env: OPALTestEnvironment) -> bool: + """Analyze container logs for critical errors""" + error_keywords = ["ERROR", "CRITICAL", "FATAL", "Exception"] + found_errors = False + + logger.info("Analyzing container logs") + for name, container in env.containers.items(): + try: + logs = container.get_logs()[0].decode() + container_errors = [ + line.strip() for line in logs.split('\n') + if any(keyword in line for keyword in error_keywords) + ] + + if container_errors: + logger.warning(f"Found errors in {name} logs:") + for error in container_errors[:5]: # Show first 5 errors + logger.warning(f"{name}: {error}") + if len(container_errors) > 5: + logger.warning(f"... and {len(container_errors) - 5} more errors") + found_errors = True + else: + logger.info(f"{name}: No critical errors found") + + except Exception as e: + logger.error(f"Error getting logs for {name}: {str(e)}") + found_errors = True + + return not found_errors \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 656fe7c60..4422bf7e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,7 @@ pytest-asyncio pytest-rerunfailures wheel>=0.38.0 twine +testcontainers +docker setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability