"""Container integration tests for FlaskPaste. These tests verify the container build and runtime behavior. They require podman or docker to be available on the system. Run with: FLASKPASTE_INTEGRATION=1 pytest tests/test_container_integration.py -v Skip in normal test runs: pytest tests/ # automatically skips these tests Note: S603 (subprocess call) warnings are suppressed as these are legitimate container management operations in test code. """ # ruff: noqa: S603 import os import shutil import subprocess import time import pytest # Skip module entirely if integration tests are disabled # This allows the file to be collected without requiring 'requests' _INTEGRATION_ENABLED = bool(os.environ.get("FLASKPASTE_INTEGRATION")) if _INTEGRATION_ENABLED: import requests else: requests = None # type: ignore[assignment] # Configuration CONTAINER_NAME = "flaskpaste-test" IMAGE_NAME = "flaskpaste:test" HOST_PORT = 5099 # Use non-standard port to avoid conflicts CONTAINER_PORT = 5000 BASE_URL = f"http://localhost:{HOST_PORT}" BUILD_TIMEOUT = 180 # seconds (increased for slow systems) START_TIMEOUT = 30 # seconds REQUEST_TIMEOUT = 5 # seconds def get_container_runtime() -> str | None: """Detect available container runtime (podman or docker).""" for runtime in ["podman", "docker"]: if shutil.which(runtime): return runtime return None def should_run_integration_tests() -> bool: """Check if integration tests should run. Integration tests only run when FLASKPASTE_INTEGRATION=1 environment variable is set AND a container runtime is available. """ if not _INTEGRATION_ENABLED: return False return get_container_runtime() is not None # Skip all tests unless explicitly enabled pytestmark = pytest.mark.skipif( not should_run_integration_tests(), reason="Set FLASKPASTE_INTEGRATION=1 to run container integration tests", ) @pytest.fixture(scope="module") def container_runtime(): """Return the available container runtime.""" runtime = get_container_runtime() if runtime is None: pytest.skip("No container runtime available") return runtime @pytest.fixture(scope="module") def project_root(): """Return the project root directory.""" return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @pytest.fixture(scope="module") def built_image(container_runtime, project_root): """Build the container image for testing.""" # Build the image result = subprocess.run( [container_runtime, "build", "-t", IMAGE_NAME, "."], cwd=project_root, capture_output=True, text=True, timeout=BUILD_TIMEOUT, ) if result.returncode != 0: pytest.fail(f"Failed to build container image:\n{result.stderr}") yield IMAGE_NAME # Cleanup: remove the test image subprocess.run( [container_runtime, "rmi", "-f", IMAGE_NAME], capture_output=True, timeout=30, ) @pytest.fixture(scope="module") def running_container(container_runtime, built_image): """Start a container for testing and yield the base URL.""" # Stop any existing container with same name subprocess.run( [container_runtime, "rm", "-f", CONTAINER_NAME], capture_output=True, timeout=30, ) # Start container result = subprocess.run( [ container_runtime, "run", "-d", "--name", CONTAINER_NAME, "-p", f"{HOST_PORT}:{CONTAINER_PORT}", "-e", "FLASK_ENV=production", built_image, ], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: pytest.fail(f"Failed to start container:\n{result.stderr}") # Wait for container to be healthy start_time = time.time() healthy = False last_error = None while time.time() - start_time < START_TIMEOUT: try: response = requests.get(f"{BASE_URL}/health", timeout=REQUEST_TIMEOUT) if response.status_code == 200: healthy = True break except requests.RequestException as e: last_error = e time.sleep(1) if not healthy: # Get container logs for debugging logs = subprocess.run( [container_runtime, "logs", CONTAINER_NAME], capture_output=True, text=True, timeout=30, ) subprocess.run( [container_runtime, "rm", "-f", CONTAINER_NAME], capture_output=True, timeout=30 ) pytest.fail( f"Container failed to become healthy within {START_TIMEOUT}s.\n" f"Last error: {last_error}\n" f"Container logs:\n{logs.stdout}\n{logs.stderr}" ) yield BASE_URL # Cleanup: stop and remove container subprocess.run([container_runtime, "rm", "-f", CONTAINER_NAME], capture_output=True, timeout=30) class TestContainerBuild: """Test container image build process.""" def test_image_builds_successfully(self, built_image): """Container image should build without errors.""" assert built_image == IMAGE_NAME def test_image_has_expected_labels(self, container_runtime, built_image): """Container image should have expected labels.""" result = subprocess.run( [container_runtime, "inspect", "--format", "{{.Config.Labels}}", built_image], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0 # Check for expected label content labels = result.stdout assert "flaskpaste" in labels.lower() or "pastebin" in labels.lower() def test_image_has_healthcheck(self, container_runtime, built_image): """Container image should define a healthcheck.""" result = subprocess.run( [container_runtime, "inspect", "--format", "{{.Config.Healthcheck}}", built_image], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0 # Should have healthcheck defined assert "CMD" in result.stdout or "python" in result.stdout class TestContainerRuntime: """Test container runtime behavior.""" def test_health_endpoint_responds(self, running_container): """Health endpoint should respond with 200.""" response = requests.get(f"{running_container}/health", timeout=REQUEST_TIMEOUT) assert response.status_code == 200 data = response.json() assert data.get("status") == "healthy" def test_paste_creation(self, running_container): """Should be able to create a paste.""" response = requests.post( running_container, data=b"Test content from container integration test", headers={"Content-Type": "text/plain"}, timeout=REQUEST_TIMEOUT, ) assert response.status_code == 201 data = response.json() assert "id" in data assert "url" in data def test_paste_retrieval(self, running_container): """Should be able to create and retrieve a paste.""" # Create content = b"Retrieve test content" create_response = requests.post( running_container, data=content, headers={"Content-Type": "text/plain"}, timeout=REQUEST_TIMEOUT, ) assert create_response.status_code == 201 paste_id = create_response.json()["id"] # Retrieve get_response = requests.get(f"{running_container}/raw/{paste_id}", timeout=REQUEST_TIMEOUT) assert get_response.status_code == 200 assert get_response.content == content def test_paste_info(self, running_container): """Should be able to get paste info.""" # Create create_response = requests.post( running_container, data=b"Info test content", headers={"Content-Type": "text/plain"}, timeout=REQUEST_TIMEOUT, ) paste_id = create_response.json()["id"] # Get info info_response = requests.get(f"{running_container}/{paste_id}", timeout=REQUEST_TIMEOUT) assert info_response.status_code == 200 info = info_response.json() assert info["id"] == paste_id assert info["size"] == len(b"Info test content") assert info["mime_type"] == "text/plain" def test_security_headers(self, running_container): """Response should include security headers.""" response = requests.get(f"{running_container}/health", timeout=REQUEST_TIMEOUT) # Check for security headers assert response.headers.get("X-Content-Type-Options") == "nosniff" assert response.headers.get("X-Frame-Options") == "DENY" assert "no-store" in response.headers.get("Cache-Control", "") def test_json_responses(self, running_container): """API responses should be JSON.""" # Health endpoint health_response = requests.get(f"{running_container}/health", timeout=REQUEST_TIMEOUT) assert health_response.headers.get("Content-Type") == "application/json" # Create paste create_response = requests.post( running_container, data=b"JSON test", timeout=REQUEST_TIMEOUT, ) assert "application/json" in create_response.headers.get("Content-Type", "") def test_not_found_returns_json(self, running_container): """404 errors should return JSON.""" response = requests.get(f"{running_container}/raw/nonexistent1234", timeout=REQUEST_TIMEOUT) assert response.status_code == 404 assert "application/json" in response.headers.get("Content-Type", "") data = response.json() assert "error" in data def test_pow_challenge_endpoint(self, running_container): """PoW challenge endpoint should be accessible.""" response = requests.get(f"{running_container}/pow/challenge", timeout=REQUEST_TIMEOUT) # PoW may or may not be enabled, but endpoint should exist assert response.status_code in (200, 400, 404) def test_large_paste_creation(self, running_container): """Should handle reasonably sized pastes.""" # Create 100KB paste content = b"x" * (100 * 1024) response = requests.post( running_container, data=content, headers={"Content-Type": "application/octet-stream"}, timeout=REQUEST_TIMEOUT * 2, ) assert response.status_code == 201 # Verify retrieval paste_id = response.json()["id"] get_response = requests.get( f"{running_container}/raw/{paste_id}", timeout=REQUEST_TIMEOUT * 2 ) assert get_response.status_code == 200 assert len(get_response.content) == len(content) class TestContainerEnvironment: """Test container environment configuration.""" def test_runs_as_non_root(self, container_runtime, running_container): """Container should run as non-root user.""" result = subprocess.run( [container_runtime, "exec", CONTAINER_NAME, "id", "-u"], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0 uid = int(result.stdout.strip()) assert uid != 0, "Container should not run as root (UID 0)" def test_data_directory_writable(self, container_runtime, running_container): """Data directory should be writable.""" result = subprocess.run( [container_runtime, "exec", CONTAINER_NAME, "touch", "/app/data/test-write"], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0 def test_gunicorn_workers(self, container_runtime, running_container): """Should have multiple gunicorn workers.""" result = subprocess.run( [container_runtime, "exec", CONTAINER_NAME, "pgrep", "-c", "gunicorn"], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0 worker_count = int(result.stdout.strip()) # Should have master + at least 2 workers assert worker_count >= 3, f"Expected at least 3 gunicorn processes, got {worker_count}"