#!/usr/bin/env python3 """ Wine CSP Integration Tests Comprehensive test suite for the Wine CSP HTTP service. Designed for pytest with JUnit XML output for CI integration. Usage: pytest test_wine_csp.py -v --junitxml=results/junit.xml pytest test_wine_csp.py -v -k "test_health" pytest test_wine_csp.py -v --wine-csp-url=http://localhost:5099 """ import base64 import json import os import time from typing import Any, Dict, Optional import pytest import requests # ============================================================================== # Configuration # ============================================================================== WINE_CSP_URL = os.environ.get("WINE_CSP_URL", "http://127.0.0.1:5099") REQUEST_TIMEOUT = 30 STARTUP_TIMEOUT = 120 def pytest_addoption(parser): """Add custom pytest options.""" parser.addoption( "--wine-csp-url", action="store", default=WINE_CSP_URL, help="Wine CSP service URL", ) @pytest.fixture(scope="session") def wine_csp_url(request): """Get Wine CSP URL from command line or environment.""" return request.config.getoption("--wine-csp-url") or WINE_CSP_URL @pytest.fixture(scope="session") def wine_csp_client(wine_csp_url): """Create a requests session for Wine CSP API calls.""" session = requests.Session() session.headers.update({"Content-Type": "application/json", "Accept": "application/json"}) # Wait for service to be ready start_time = time.time() while time.time() - start_time < STARTUP_TIMEOUT: try: response = session.get(f"{wine_csp_url}/health", timeout=5) if response.status_code == 200: break except requests.exceptions.RequestException: pass time.sleep(5) else: pytest.fail(f"Wine CSP service not ready after {STARTUP_TIMEOUT}s") return {"session": session, "base_url": wine_csp_url} # ============================================================================== # Helper Functions # ============================================================================== def get(client: Dict, endpoint: str) -> requests.Response: """Perform GET request.""" return client["session"].get( f"{client['base_url']}{endpoint}", timeout=REQUEST_TIMEOUT ) def post(client: Dict, endpoint: str, data: Dict[str, Any]) -> requests.Response: """Perform POST request with JSON body.""" return client["session"].post( f"{client['base_url']}{endpoint}", json=data, timeout=REQUEST_TIMEOUT ) def encode_b64(text: str) -> str: """Encode string to base64.""" return base64.b64encode(text.encode("utf-8")).decode("utf-8") def decode_b64(b64: str) -> bytes: """Decode base64 string.""" return base64.b64decode(b64) # ============================================================================== # Health Endpoint Tests # ============================================================================== class TestHealthEndpoints: """Tests for health check endpoints.""" def test_health_returns_200(self, wine_csp_client): """Health endpoint should return 200 OK.""" response = get(wine_csp_client, "/health") assert response.status_code == 200 def test_health_returns_status(self, wine_csp_client): """Health endpoint should return status field.""" response = get(wine_csp_client, "/health") data = response.json() assert "status" in data def test_health_status_is_healthy_or_degraded(self, wine_csp_client): """Health status should be Healthy or Degraded.""" response = get(wine_csp_client, "/health") data = response.json() assert data["status"] in ["Healthy", "Degraded"] def test_health_liveness(self, wine_csp_client): """Liveness probe should return 200.""" response = get(wine_csp_client, "/health/liveness") assert response.status_code == 200 def test_health_readiness(self, wine_csp_client): """Readiness probe should return 200.""" response = get(wine_csp_client, "/health/readiness") assert response.status_code == 200 # ============================================================================== # Status Endpoint Tests # ============================================================================== class TestStatusEndpoint: """Tests for status endpoint.""" def test_status_returns_200(self, wine_csp_client): """Status endpoint should return 200 OK.""" response = get(wine_csp_client, "/status") assert response.status_code == 200 def test_status_contains_service_name(self, wine_csp_client): """Status should contain serviceName.""" response = get(wine_csp_client, "/status") data = response.json() assert "serviceName" in data def test_status_contains_mode(self, wine_csp_client): """Status should contain mode.""" response = get(wine_csp_client, "/status") data = response.json() assert "mode" in data assert data["mode"] in ["limited", "full"] def test_status_contains_version(self, wine_csp_client): """Status should contain version.""" response = get(wine_csp_client, "/status") data = response.json() assert "version" in data or "serviceVersion" in data # ============================================================================== # Keys Endpoint Tests # ============================================================================== class TestKeysEndpoint: """Tests for keys endpoint.""" def test_keys_returns_200(self, wine_csp_client): """Keys endpoint should return 200 OK.""" response = get(wine_csp_client, "/keys") assert response.status_code == 200 def test_keys_returns_array(self, wine_csp_client): """Keys endpoint should return an array.""" response = get(wine_csp_client, "/keys") data = response.json() assert isinstance(data, list) # ============================================================================== # Hash Endpoint Tests # ============================================================================== class TestHashEndpoint: """Tests for hash operations.""" @pytest.mark.parametrize( "algorithm", ["STREEBOG-256", "STREEBOG-512", "GOST3411-256", "GOST3411-512"], ) def test_hash_algorithms(self, wine_csp_client, algorithm): """Test supported hash algorithms.""" data = {"algorithm": algorithm, "data": encode_b64("Hello World")} response = post(wine_csp_client, "/hash", data) # May return 200 or 400 depending on algorithm support assert response.status_code in [200, 400] def test_hash_streebog256_returns_hash(self, wine_csp_client): """Streebog-256 should return a hash.""" data = {"algorithm": "STREEBOG-256", "data": encode_b64("Hello")} response = post(wine_csp_client, "/hash", data) assert response.status_code == 200 result = response.json() assert "hash" in result assert len(result["hash"]) == 64 # 256 bits = 64 hex chars def test_hash_streebog512_returns_hash(self, wine_csp_client): """Streebog-512 should return a hash.""" data = {"algorithm": "STREEBOG-512", "data": encode_b64("Hello")} response = post(wine_csp_client, "/hash", data) assert response.status_code == 200 result = response.json() assert "hash" in result assert len(result["hash"]) == 128 # 512 bits = 128 hex chars def test_hash_empty_input(self, wine_csp_client): """Hash of empty input should work.""" data = {"algorithm": "STREEBOG-256", "data": ""} response = post(wine_csp_client, "/hash", data) assert response.status_code == 200 def test_hash_invalid_algorithm(self, wine_csp_client): """Invalid algorithm should return 400.""" data = {"algorithm": "INVALID-ALGO", "data": encode_b64("Hello")} response = post(wine_csp_client, "/hash", data) assert response.status_code == 400 def test_hash_missing_data(self, wine_csp_client): """Missing data field should return 400.""" data = {"algorithm": "STREEBOG-256"} response = post(wine_csp_client, "/hash", data) assert response.status_code == 400 def test_hash_missing_algorithm(self, wine_csp_client): """Missing algorithm field should return 400.""" data = {"data": encode_b64("Hello")} response = post(wine_csp_client, "/hash", data) assert response.status_code == 400 # ============================================================================== # Determinism Tests # ============================================================================== class TestDeterminism: """Tests for deterministic behavior.""" def test_hash_determinism_same_input(self, wine_csp_client): """Same input should produce same hash.""" data = {"algorithm": "STREEBOG-256", "data": encode_b64("Test data for determinism")} hashes = [] for _ in range(5): response = post(wine_csp_client, "/hash", data) assert response.status_code == 200 hashes.append(response.json()["hash"]) # All hashes should be identical assert len(set(hashes)) == 1, f"Non-deterministic hashes: {hashes}" def test_hash_determinism_binary_data(self, wine_csp_client): """Binary input should produce deterministic hash.""" binary_data = bytes(range(256)) data = {"algorithm": "STREEBOG-512", "data": base64.b64encode(binary_data).decode()} hashes = [] for _ in range(5): response = post(wine_csp_client, "/hash", data) assert response.status_code == 200 hashes.append(response.json()["hash"]) assert len(set(hashes)) == 1 # ============================================================================== # Known Test Vector Validation # ============================================================================== class TestKnownVectors: """Tests using known GOST test vectors.""" def test_streebog256_m1_vector(self, wine_csp_client): """Validate Streebog-256 against GOST R 34.11-2012 M1 test vector.""" # M1 = "012345678901234567890123456789012345678901234567890123456789012" m1 = "012345678901234567890123456789012345678901234567890123456789012" expected_hash = "9d151eefd8590b89daa6ba6cb74af9275dd051026bb149a452fd84e5e57b5500" data = {"algorithm": "STREEBOG-256", "data": encode_b64(m1)} response = post(wine_csp_client, "/hash", data) if response.status_code == 200: result = response.json() # Note: Implementation may use different encoding actual_hash = result["hash"].lower() # Check if hash matches (may need to reverse bytes for some implementations) assert len(actual_hash) == 64, f"Invalid hash length: {len(actual_hash)}" # Log for debugging print(f"Expected: {expected_hash}") print(f"Actual: {actual_hash}") def test_streebog512_m1_vector(self, wine_csp_client): """Validate Streebog-512 against GOST R 34.11-2012 M1 test vector.""" m1 = "012345678901234567890123456789012345678901234567890123456789012" expected_hash = "1b54d01a4af5b9d5cc3d86d68d285462b19abc2475222f35c085122be4ba1ffa00ad30f8767b3a82384c6574f024c311e2a481332b08ef7f41797891c1646f48" data = {"algorithm": "STREEBOG-512", "data": encode_b64(m1)} response = post(wine_csp_client, "/hash", data) if response.status_code == 200: result = response.json() actual_hash = result["hash"].lower() assert len(actual_hash) == 128, f"Invalid hash length: {len(actual_hash)}" print(f"Expected: {expected_hash}") print(f"Actual: {actual_hash}") # ============================================================================== # Test Vectors Endpoint # ============================================================================== class TestTestVectorsEndpoint: """Tests for test vectors endpoint.""" def test_vectors_returns_200(self, wine_csp_client): """Test vectors endpoint should return 200.""" response = get(wine_csp_client, "/test-vectors") assert response.status_code == 200 def test_vectors_returns_array_or_object(self, wine_csp_client): """Test vectors should return valid JSON.""" response = get(wine_csp_client, "/test-vectors") data = response.json() assert isinstance(data, (list, dict)) # ============================================================================== # Sign/Verify Endpoint Tests # ============================================================================== class TestSignVerifyEndpoints: """Tests for sign and verify operations.""" def test_sign_without_key_returns_error(self, wine_csp_client): """Sign without valid key should return error in limited mode.""" data = { "keyId": "nonexistent-key", "algorithm": "GOST12-256", "data": encode_b64("Test message"), } response = post(wine_csp_client, "/sign", data) # Should return error (400 or 404) in limited mode assert response.status_code in [200, 400, 404, 500] def test_verify_invalid_signature(self, wine_csp_client): """Verify with invalid signature should fail.""" data = { "keyId": "test-key", "algorithm": "GOST12-256", "data": encode_b64("Test message"), "signature": "aW52YWxpZA==", # "invalid" in base64 } response = post(wine_csp_client, "/verify", data) # Should return error or false verification assert response.status_code in [200, 400, 404, 500] # ============================================================================== # Error Handling Tests # ============================================================================== class TestErrorHandling: """Tests for error handling.""" def test_malformed_json(self, wine_csp_client): """Malformed JSON should return 400.""" response = wine_csp_client["session"].post( f"{wine_csp_client['base_url']}/hash", data="not valid json", headers={"Content-Type": "application/json"}, timeout=REQUEST_TIMEOUT, ) assert response.status_code == 400 def test_invalid_base64(self, wine_csp_client): """Invalid base64 should return 400.""" data = {"algorithm": "STREEBOG-256", "data": "not-valid-base64!!!"} response = post(wine_csp_client, "/hash", data) assert response.status_code == 400 def test_unknown_endpoint(self, wine_csp_client): """Unknown endpoint should return 404.""" response = get(wine_csp_client, "/unknown-endpoint") assert response.status_code == 404 # ============================================================================== # Performance Tests # ============================================================================== class TestPerformance: """Performance benchmark tests.""" @pytest.mark.slow def test_hash_throughput(self, wine_csp_client): """Hash operations should meet minimum throughput.""" data = {"algorithm": "STREEBOG-256", "data": encode_b64("X" * 1024)} iterations = 50 start_time = time.time() for _ in range(iterations): response = post(wine_csp_client, "/hash", data) assert response.status_code == 200 elapsed = time.time() - start_time ops_per_second = iterations / elapsed print(f"Hash throughput: {ops_per_second:.2f} ops/sec") print(f"Average latency: {(elapsed / iterations) * 1000:.2f} ms") # Should achieve at least 5 ops/sec assert ops_per_second >= 5, f"Throughput too low: {ops_per_second:.2f} ops/sec" @pytest.mark.slow def test_concurrent_requests(self, wine_csp_client): """Service should handle concurrent requests.""" import concurrent.futures data = {"algorithm": "STREEBOG-256", "data": encode_b64("Concurrent test")} def make_request(): return post(wine_csp_client, "/hash", data) with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(make_request) for _ in range(20)] results = [f.result() for f in concurrent.futures.as_completed(futures)] success_count = sum(1 for r in results if r.status_code == 200) assert success_count >= 18, f"Too many failures: {20 - success_count}/20" # ============================================================================== # Main # ============================================================================== if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])