From 4e4726d5f72209509ad1f372c9484fda2ded81f0 Mon Sep 17 00:00:00 2001 From: Yusuf Nathani Date: Tue, 10 Feb 2026 21:41:21 -0500 Subject: [PATCH 1/8] feat: add Python integration tests to CI pipeline Add pytest-based integration testing infrastructure for PolicyEngine: - Add integration-tests job to CI workflow that depends on go-build - Download and test built linux-amd64 binary artifacts - Create pytest configuration with test markers (unit, integration, smoke, api, grpc) - Add shared fixtures for mpe binary, server setup, and test data - Include sample tests demonstrating CLI, structure, and API testing patterns - Add test runner script with virtual environment management - Configure Python dependencies in requirements-test.txt - Update .gitignore to exclude Python cache and test artifacts The integration-tests job runs automatically on push/PR and validates that the built binary works correctly from a client perspective. Signed-off-by: Yusuf Nathani --- .github/workflows/ci.yml | 57 ++++++++++++ .gitignore | 9 ++ pytest.ini | 38 ++++++++ requirements-test.txt | 24 +++++ scripts/run-tests.sh | 100 +++++++++++++++++++++ tests/README.md | 185 +++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 12 +++ tests/conftest.py | 142 ++++++++++++++++++++++++++++++ tests/test_sample.py | 119 +++++++++++++++++++++++++ 9 files changed, 686 insertions(+) create mode 100644 pytest.ini create mode 100644 requirements-test.txt create mode 100755 scripts/run-tests.sh create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_sample.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c88ca..d101d02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,6 +237,63 @@ jobs: name: mpe-${{ matrix.goos }}-${{ matrix.goarch }} path: target/mpe-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: [go-build] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download Linux AMD64 binary + uses: actions/download-artifact@v4 + with: + name: mpe-linux-amd64 + path: target/ + + - name: Make binary executable + run: chmod +x target/mpe-linux-amd64 + + - name: Create symlink for tests + run: | + mkdir -p target + ln -sf mpe-linux-amd64 target/mpe + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: requirements-test.txt + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Run pytest + run: | + pytest -v --tb=short --color=yes + + - name: Generate test summary + if: always() + run: | + echo "## Python Integration Test Results" >> $GITHUB_STEP_SUMMARY + if [ $? -eq 0 ]; then + echo "✅ All integration tests passed!" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Some integration tests failed." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-results + path: | + .pytest_cache/ + htmlcov/ + example-validation: name: Validate Example PolicyDomains runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7f602fb..cb60426 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,12 @@ target/ # Generated during artifact builds NOTICES +# Python +__pycache__/ +*.py[cod] +*.class +.pytest_cache/ +.venv-test/ +htmlcov/ +.coverage +coverage.out diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..990649a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,38 @@ +[pytest] +# Pytest configuration for PolicyEngine + +# Test discovery patterns +python_files = test_*.py *_test.py +python_classes = Test* *Tests +python_functions = test_* + +# Test paths +testpaths = tests + +# Output options +addopts = + -v + --strict-markers + --tb=short + --color=yes + -ra + +# Markers for organizing tests +markers = + unit: Unit tests + integration: Integration tests requiring PolicyEngine service + smoke: Quick smoke tests + api: HTTP API tests + grpc: gRPC service tests + policy: PolicyDomain validation tests + slow: Tests that take significant time + +# Logging +log_cli = false +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S + +# Coverage options (if pytest-cov is installed) +# [tool:pytest] +# addopts = --cov=tests --cov-report=html --cov-report=term diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..f690b19 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,24 @@ +# Python testing dependencies for PolicyEngine + +# Core testing framework +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-timeout>=2.1.0 +pytest-xdist>=3.3.0 # Parallel test execution + +# Coverage reporting +pytest-cov>=4.1.0 + +# HTTP client for API testing +requests>=2.31.0 +httpx>=0.24.0 # Async HTTP client + +# gRPC client (if testing gRPC endpoints) +grpcio>=1.56.0 +grpcio-tools>=1.56.0 + +# YAML parsing (for PolicyDomain files) +PyYAML>=6.0 + +# Utilities +python-dotenv>=1.0.0 # Environment variable management diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..3e2a728 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Run PolicyEngine Python integration tests +# + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo -e "${GREEN}PolicyEngine Test Runner${NC}" +echo "================================" + +# Check if Python 3 is available +if ! command -v python3 &> /dev/null; then + echo -e "${RED}Error: python3 is not installed${NC}" + exit 1 +fi + +# Check if virtual environment exists, create if not +VENV_DIR="${PROJECT_ROOT}/.venv-test" +if [ ! -d "$VENV_DIR" ]; then + echo -e "${YELLOW}Creating Python virtual environment...${NC}" + python3 -m venv "$VENV_DIR" +fi + +# Activate virtual environment +echo -e "${YELLOW}Activating virtual environment...${NC}" +source "${VENV_DIR}/bin/activate" + +# Install/upgrade dependencies +echo -e "${YELLOW}Installing test dependencies...${NC}" +pip install -q --upgrade pip +pip install -q -r "${PROJECT_ROOT}/requirements-test.txt" + +# Build the mpe binary if it doesn't exist +if [ ! -f "${PROJECT_ROOT}/target/mpe" ]; then + echo -e "${YELLOW}Building mpe binary...${NC}" + cd "$PROJECT_ROOT" + make build +fi + +# Parse arguments +PYTEST_ARGS=() +RUN_INTEGRATION=false + +while [[ $# -gt 0 ]]; do + case $1 in + --integration|-i) + RUN_INTEGRATION=true + shift + ;; + --smoke|-s) + PYTEST_ARGS+=("-m" "smoke") + shift + ;; + --coverage|-c) + PYTEST_ARGS+=("--cov=tests" "--cov-report=html" "--cov-report=term") + shift + ;; + *) + PYTEST_ARGS+=("$1") + shift + ;; + esac +done + +# Default: skip integration tests unless explicitly requested +if [ "$RUN_INTEGRATION" = false ]; then + PYTEST_ARGS+=("-m" "not integration") +fi + +# Run pytest +echo -e "${GREEN}Running tests...${NC}" +echo "Command: pytest ${PYTEST_ARGS[*]}" +echo "" + +cd "$PROJECT_ROOT" +pytest "${PYTEST_ARGS[@]}" +TEST_EXIT_CODE=$? + +# Deactivate virtual environment +deactivate + +# Print results +echo "" +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✅ All tests passed!${NC}" +else + echo -e "${RED}❌ Tests failed with exit code ${TEST_EXIT_CODE}${NC}" +fi + +exit $TEST_EXIT_CODE diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9d60173 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,185 @@ +# PolicyEngine Python Integration Tests + +This directory contains Python-based integration and end-to-end tests for the Manetu PolicyEngine. + +## Overview + +While the PolicyEngine is written in Go, this Python test suite provides: + +- **Integration Testing**: Test the HTTP and gRPC APIs from a client perspective +- **End-to-End Testing**: Validate complete policy evaluation workflows +- **PolicyDomain Validation**: Test PolicyDomain configurations +- **Cross-Language Testing**: Ensure APIs work correctly from non-Go clients + +## Quick Start + +### Setup + +```bash +# Option 1: Use the test runner script (recommended) +./scripts/run-tests.sh + +# Option 2: Manual setup +python3 -m venv .venv-test +source .venv-test/bin/activate +pip install -r requirements-test.txt +pytest +``` + +### Running Tests + +```bash +# Run all tests (excluding integration tests by default) +./scripts/run-tests.sh + +# Run smoke tests only +./scripts/run-tests.sh --smoke + +# Run integration tests (requires MPE server) +./scripts/run-tests.sh --integration + +# Run with coverage report +./scripts/run-tests.sh --coverage + +# Run specific test file +pytest tests/test_sample.py + +# Run specific test +pytest tests/test_sample.py::TestPolicyEngineCLI::test_mpe_version + +# Run tests matching a keyword +pytest -k "policy" +``` + +## Test Organization + +Tests are organized using pytest markers: + +- `@pytest.mark.unit` - Fast unit tests, no external dependencies +- `@pytest.mark.integration` - Integration tests requiring MPE server +- `@pytest.mark.smoke` - Quick smoke tests for basic functionality +- `@pytest.mark.api` - HTTP API tests +- `@pytest.mark.grpc` - gRPC service tests +- `@pytest.mark.policy` - PolicyDomain validation tests +- `@pytest.mark.slow` - Tests that take significant time + +### Run tests by marker + +```bash +pytest -m unit # Only unit tests +pytest -m smoke # Only smoke tests +pytest -m "not slow" # Skip slow tests +``` + +## Project Structure + +``` +tests/ +├── __init__.py # Package initialization +├── conftest.py # Shared fixtures and configuration +├── test_sample.py # Sample tests demonstrating patterns +├── README.md # This file +└── [your test files] # Add your tests here + +pytest.ini # Pytest configuration +requirements-test.txt # Python test dependencies +scripts/run-tests.sh # Test runner script +``` + +## Writing Tests + +### Example Test + +```python +import pytest + +@pytest.mark.integration +def test_authorization_flow(mpe_server, sample_principal): + """Test a complete authorization flow.""" + import requests + + # Make authorization request + response = requests.post( + f"{mpe_server}/v1/authorize", + json={ + "principal": sample_principal, + "action": "read", + "resource": {"id": "doc-123", "type": "document"} + } + ) + + # Assert response + assert response.status_code == 200 + assert response.json()["allow"] is True +``` + +### Available Fixtures + +See `conftest.py` for all available fixtures: + +- `project_root` - Path to project root directory +- `testdata_dir` - Path to testdata directory +- `mpe_binary` - Path to built mpe CLI binary +- `mpe_server` - Running MPE server instance (starts/stops automatically) +- `sample_policy_domain` - Path to sample PolicyDomain YAML +- `sample_principal` - Sample principal for authorization tests +- `sample_resource` - Sample resource for authorization tests + +## Best Practices + +1. **Use markers** - Tag tests appropriately (unit, integration, smoke, etc.) +2. **Use fixtures** - Leverage shared fixtures in conftest.py +3. **Keep tests focused** - One test should test one thing +4. **Clean up resources** - Use fixtures for setup/teardown +5. **Skip when appropriate** - Use `@pytest.mark.skip()` for tests requiring specific setup + +## CI/CD Integration + +To integrate with CI/CD pipelines: + +```yaml +# Example GitHub Actions step +- name: Run Python Integration Tests + run: | + ./scripts/run-tests.sh --coverage +``` + +## Environment Variables + +- `MPE_TEST_PORT` - Port for test server (default: 9090) +- `MPE_TEST_TIMEOUT` - Test timeout in seconds (default: 30) + +## Troubleshooting + +### Tests failing to start MPE server + +Make sure the mpe binary is built: +```bash +make build +``` + +### Import errors + +Activate the virtual environment: +```bash +source .venv-test/bin/activate +pip install -r requirements-test.txt +``` + +### Port already in use + +Set a different port: +```bash +export MPE_TEST_PORT=9091 +pytest +``` + +## Next Steps + +This is a starting point! Expand this test suite by: + +1. Adding HTTP API integration tests +2. Adding gRPC service tests +3. Testing different PolicyDomain configurations +4. Adding performance/load tests +5. Testing error cases and edge conditions diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3cac2bf --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,12 @@ +# PolicyEngine Python Integration Tests +""" +Integration tests for the Manetu PolicyEngine. + +This test suite provides Python-based integration testing for: +- HTTP API endpoints +- gRPC service interfaces +- PolicyDomain validation +- End-to-end policy evaluation workflows +""" + +__version__ = "0.1.0" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f3cdad2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,142 @@ +""" +Pytest configuration and shared fixtures for PolicyEngine tests. +""" + +import os +import subprocess +import time +from pathlib import Path +from typing import Generator + +import pytest + + +# Project root directory +PROJECT_ROOT = Path(__file__).parent.parent + + +@pytest.fixture(scope="session") +def project_root() -> Path: + """Return the project root directory.""" + return PROJECT_ROOT + + +@pytest.fixture(scope="session") +def testdata_dir(project_root: Path) -> Path: + """Return the testdata directory path.""" + return project_root / "testdata" + + +@pytest.fixture(scope="session") +def mpe_binary(project_root: Path) -> Path: + """ + Ensure the mpe binary is built and return its path. + + This fixture builds the PolicyEngine CLI if it doesn't exist. + """ + binary_path = project_root / "target" / "mpe" + + if not binary_path.exists(): + print("\n🔨 Building mpe binary...") + subprocess.run( + ["make", "build"], + cwd=project_root, + check=True, + capture_output=True + ) + + assert binary_path.exists(), "Failed to build mpe binary" + return binary_path + + +@pytest.fixture +def sample_policy_domain(testdata_dir: Path) -> Path: + """Return path to a sample PolicyDomain YAML file.""" + policy_file = testdata_dir / "mpe-config.yaml" + assert policy_file.exists(), f"Sample policy not found: {policy_file}" + return policy_file + + +@pytest.fixture(scope="session") +def mpe_server_port() -> int: + """Return the port for the MPE server during tests.""" + return int(os.getenv("MPE_TEST_PORT", "9090")) + + +@pytest.fixture(scope="function") +def mpe_server( + mpe_binary: Path, + sample_policy_domain: Path, + mpe_server_port: int +) -> Generator[str, None, None]: + """ + Start a PolicyEngine server for integration tests. + + Yields: + Base URL of the running server (e.g., "http://localhost:9090") + """ + server_url = f"http://localhost:{mpe_server_port}" + + # Start the server + print(f"\n🚀 Starting MPE server on {server_url}...") + process = subprocess.Popen( + [ + str(mpe_binary), + "serve", + "--bundle", str(sample_policy_domain), + "--http-port", str(mpe_server_port), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=PROJECT_ROOT + ) + + # Wait for server to be ready + max_attempts = 30 + for attempt in range(max_attempts): + try: + import requests + response = requests.get(f"{server_url}/health", timeout=1) + if response.status_code == 200: + print(f"✅ MPE server ready after {attempt + 1} attempts") + break + except Exception: + time.sleep(0.5) + else: + process.kill() + raise RuntimeError("MPE server failed to start within timeout") + + yield server_url + + # Cleanup + print("\n🛑 Stopping MPE server...") + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +@pytest.fixture +def sample_principal() -> dict: + """Return a sample principal for testing authorization.""" + return { + "id": "user-123", + "email": "test@example.com", + "roles": ["admin"], + "attributes": { + "department": "engineering", + "clearance": "high" + } + } + + +@pytest.fixture +def sample_resource() -> dict: + """Return a sample resource for testing authorization.""" + return { + "id": "resource-456", + "type": "document", + "owner": "user-123", + "classification": "confidential" + } diff --git a/tests/test_sample.py b/tests/test_sample.py new file mode 100644 index 0000000..bd8de30 --- /dev/null +++ b/tests/test_sample.py @@ -0,0 +1,119 @@ +""" +Sample tests for PolicyEngine - demonstrating test patterns. +""" + +import subprocess +from pathlib import Path + +import pytest + + +class TestPolicyEngineCLI: + """Test suite for MPE CLI commands.""" + + def test_mpe_binary_exists(self, mpe_binary: Path): + """Test that the mpe binary is available.""" + assert mpe_binary.exists() + assert mpe_binary.is_file() + + def test_mpe_version(self, mpe_binary: Path): + """Test that mpe version works.""" + result = subprocess.run( + [str(mpe_binary), "version"], + capture_output=True, + text=True + ) + assert result.returncode == 0 + assert len(result.stdout.strip()) > 0, "Version output should not be empty" + + @pytest.mark.policy + def test_lint_sample_policy(self, mpe_binary: Path, sample_policy_domain: Path): + """Test linting a sample PolicyDomain.""" + result = subprocess.run( + [str(mpe_binary), "lint", "-f", str(sample_policy_domain)], + capture_output=True, + text=True + ) + # Lint should pass or at least not crash + assert result.returncode in [0, 1] # 0 = pass, 1 = warnings + + +@pytest.mark.unit +class TestProjectStructure: + """Basic tests for project structure.""" + + def test_project_root_exists(self, project_root: Path): + """Test that project root is valid.""" + assert project_root.exists() + assert project_root.is_dir() + + def test_testdata_exists(self, testdata_dir: Path): + """Test that testdata directory exists.""" + assert testdata_dir.exists() + assert testdata_dir.is_dir() + + def test_sample_policy_domain_exists(self, sample_policy_domain: Path): + """Test that sample PolicyDomain file exists.""" + assert sample_policy_domain.exists() + assert sample_policy_domain.suffix in [".yaml", ".yml"] + + +@pytest.mark.integration +@pytest.mark.skip(reason="Requires running MPE server - enable when ready") +class TestPolicyEngineAPI: + """Integration tests for PolicyEngine HTTP API.""" + + def test_health_endpoint(self, mpe_server: str): + """Test the health check endpoint.""" + import requests + + response = requests.get(f"{mpe_server}/health") + assert response.status_code == 200 + + def test_authorization_decision( + self, + mpe_server: str, + sample_principal: dict, + sample_resource: dict + ): + """Test making an authorization decision.""" + import requests + + decision_request = { + "principal": sample_principal, + "action": "read", + "resource": sample_resource, + } + + response = requests.post( + f"{mpe_server}/v1/authorize", + json=decision_request + ) + + assert response.status_code == 200 + data = response.json() + assert "allow" in data + assert isinstance(data["allow"], bool) + + +@pytest.mark.smoke +class TestQuickSmoke: + """Quick smoke tests that should always pass.""" + + def test_imports(self): + """Test that required libraries can be imported.""" + import pytest + import yaml + assert pytest is not None + assert yaml is not None + + def test_fixtures_available( + self, + project_root: Path, + sample_principal: dict, + sample_resource: dict + ): + """Test that common fixtures are available.""" + assert project_root.exists() + assert sample_principal["id"] == "user-123" + assert sample_resource["type"] == "document" From 2de7877b55fb7a639561db84d173277dd9946b08 Mon Sep 17 00:00:00 2001 From: Yusuf Nathani Date: Tue, 10 Feb 2026 22:25:04 -0500 Subject: [PATCH 2/8] test: add CLI help and PolicyDomain YAML validation tests Add two new test cases to verify CI pipeline automation: - test_mpe_help_command: Validates mpe --help output and available commands - test_policy_domain_structure: Validates YAML structure of PolicyDomain files These tests ensure the binary works correctly and policy files are well-formed. Signed-off-by: Yusuf Nathani --- tests/test_sample.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_sample.py b/tests/test_sample.py index bd8de30..879f350 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -37,6 +37,21 @@ def test_lint_sample_policy(self, mpe_binary: Path, sample_policy_domain: Path): # Lint should pass or at least not crash assert result.returncode in [0, 1] # 0 = pass, 1 = warnings + def test_mpe_help_command(self, mpe_binary: Path): + """Test that mpe --help displays usage information.""" + result = subprocess.run( + [str(mpe_binary), "--help"], + capture_output=True, + text=True + ) + assert result.returncode == 0 + assert "mpe" in result.stdout.lower() + assert "COMMANDS" in result.stdout or "commands" in result.stdout + # Should show main commands + assert "test" in result.stdout + assert "serve" in result.stdout + assert "lint" in result.stdout + @pytest.mark.unit class TestProjectStructure: @@ -57,6 +72,23 @@ def test_sample_policy_domain_exists(self, sample_policy_domain: Path): assert sample_policy_domain.exists() assert sample_policy_domain.suffix in [".yaml", ".yml"] + def test_policy_domain_structure(self, sample_policy_domain: Path): + """Test that PolicyDomain file has valid YAML structure.""" + import yaml + + with open(sample_policy_domain, 'r') as f: + config = yaml.safe_load(f) + + # Verify it's a valid YAML + assert config is not None + assert isinstance(config, dict) + + # Should have either PolicyDomain structure or mock config structure + is_policy_domain = "apiVersion" in config and "kind" in config + is_mock_config = "mock" in config or "include" in config + + assert is_policy_domain or is_mock_config, "File should be either a PolicyDomain or mock config" + @pytest.mark.integration @pytest.mark.skip(reason="Requires running MPE server - enable when ready") From 7c9f5d798e0ea64333bc4c2c22686400303fbc63 Mon Sep 17 00:00:00 2001 From: Joseph Rabuni Date: Wed, 11 Feb 2026 20:45:42 +0530 Subject: [PATCH 3/8] Migrate MPE CLI tests under tests/ and align test_data paths Signed-off-by: Yusuf Nathani --- requirements-test.txt | 1 + tests/README.md | 185 -------------- tests/mpe/examples/test_bad_rego_yml.py | 63 +++++ tests/mpe/examples/test_broken_alpha_yml.py | 69 ++++++ tests/mpe/examples/test_broken_beta_yml.py | 71 ++++++ .../test_broken_policy_reference_yml.py | 67 ++++++ tests/mpe/examples/test_consolidated_yml.py | 71 ++++++ tests/mpe/examples/test_malformed_yml.py | 73 ++++++ .../test_missing_role_reference_yml.py | 71 ++++++ tests/mpe/examples/test_mixed_invalid_yml.py | 72 ++++++ tests/mpe/examples/test_mixed_valid_yml.py | 70 ++++++ .../test_multiple_reference_errors_yml.py | 77 ++++++ tests/mpe/examples/test_valid_alpha_yml.py | 71 ++++++ ..._for_authorized_http_method_operation.json | 78 ++++++ .../access_allowed_when_scope_is_null.json | 76 ++++++ ...or_unauthorized_http_method_operation.json | 78 ++++++ .../access_denied_when_role_is_null.json | 76 ++++++ ...with_additional_but_irrelevant_scopes.json | 79 ++++++ ...cess_denied_with_invalid_scope_format.json | 80 +++++++ .../porc_json/admin_with_write_api_scope.json | 78 ++++++ ...dmin_with_read_scope_is_denied_access.json | 78 ++++++ .../api/payloads/porc_json/valid_admin.json | 78 ++++++ ...in_with_multiple_scopes_allows_access.json | 79 ++++++ tests/test_data/api/payloads/yml/bad_rego.yml | 10 + .../api/payloads/yml/broken_alpha.yml | 42 ++++ .../api/payloads/yml/broken_beta.yml | 49 ++++ .../api/payloads/yml/consolidated.yml | 226 ++++++++++++++++++ .../payloads/yml/invalid_policy_reference.yml | 19 ++ .../api/payloads/yml/malformed_bundle.yml | 11 + .../payloads/yml/missing_role_reference.yml | 26 ++ .../api/payloads/yml/mixed_invalid.yml | 17 ++ .../api/payloads/yml/mixed_valid.yml | 15 ++ .../api/payloads/yml/multi_error.yml | 43 ++++ .../api/payloads/yml/valid_alpha.yml | 35 +++ tests/test_sample.py | 151 ------------ tests/utils/__init__.py | 2 + tests/utils/allure_helpers.py | 26 ++ tests/utils/mpe_runner.py | 55 +++++ 38 files changed, 2132 insertions(+), 336 deletions(-) delete mode 100644 tests/README.md create mode 100644 tests/mpe/examples/test_bad_rego_yml.py create mode 100644 tests/mpe/examples/test_broken_alpha_yml.py create mode 100644 tests/mpe/examples/test_broken_beta_yml.py create mode 100644 tests/mpe/examples/test_broken_policy_reference_yml.py create mode 100644 tests/mpe/examples/test_consolidated_yml.py create mode 100644 tests/mpe/examples/test_malformed_yml.py create mode 100644 tests/mpe/examples/test_missing_role_reference_yml.py create mode 100644 tests/mpe/examples/test_mixed_invalid_yml.py create mode 100644 tests/mpe/examples/test_mixed_valid_yml.py create mode 100644 tests/mpe/examples/test_multiple_reference_errors_yml.py create mode 100644 tests/mpe/examples/test_valid_alpha_yml.py create mode 100644 tests/test_data/api/payloads/porc_json/access_allowed_for_authorized_http_method_operation.json create mode 100644 tests/test_data/api/payloads/porc_json/access_allowed_when_scope_is_null.json create mode 100644 tests/test_data/api/payloads/porc_json/access_denied_for_unauthorized_http_method_operation.json create mode 100644 tests/test_data/api/payloads/porc_json/access_denied_when_role_is_null.json create mode 100644 tests/test_data/api/payloads/porc_json/access_denied_with_additional_but_irrelevant_scopes.json create mode 100644 tests/test_data/api/payloads/porc_json/access_denied_with_invalid_scope_format.json create mode 100644 tests/test_data/api/payloads/porc_json/admin_with_write_api_scope.json create mode 100644 tests/test_data/api/payloads/porc_json/non_admin_with_read_scope_is_denied_access.json create mode 100644 tests/test_data/api/payloads/porc_json/valid_admin.json create mode 100644 tests/test_data/api/payloads/porc_json/valid_admin_with_multiple_scopes_allows_access.json create mode 100644 tests/test_data/api/payloads/yml/bad_rego.yml create mode 100644 tests/test_data/api/payloads/yml/broken_alpha.yml create mode 100644 tests/test_data/api/payloads/yml/broken_beta.yml create mode 100644 tests/test_data/api/payloads/yml/consolidated.yml create mode 100644 tests/test_data/api/payloads/yml/invalid_policy_reference.yml create mode 100644 tests/test_data/api/payloads/yml/malformed_bundle.yml create mode 100644 tests/test_data/api/payloads/yml/missing_role_reference.yml create mode 100644 tests/test_data/api/payloads/yml/mixed_invalid.yml create mode 100644 tests/test_data/api/payloads/yml/mixed_valid.yml create mode 100644 tests/test_data/api/payloads/yml/multi_error.yml create mode 100644 tests/test_data/api/payloads/yml/valid_alpha.yml delete mode 100644 tests/test_sample.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/allure_helpers.py create mode 100644 tests/utils/mpe_runner.py diff --git a/requirements-test.txt b/requirements-test.txt index f690b19..e45ad70 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -8,6 +8,7 @@ pytest-xdist>=3.3.0 # Parallel test execution # Coverage reporting pytest-cov>=4.1.0 +allure-pytest>=2.13.0 # HTTP client for API testing requests>=2.31.0 diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 9d60173..0000000 --- a/tests/README.md +++ /dev/null @@ -1,185 +0,0 @@ -# PolicyEngine Python Integration Tests - -This directory contains Python-based integration and end-to-end tests for the Manetu PolicyEngine. - -## Overview - -While the PolicyEngine is written in Go, this Python test suite provides: - -- **Integration Testing**: Test the HTTP and gRPC APIs from a client perspective -- **End-to-End Testing**: Validate complete policy evaluation workflows -- **PolicyDomain Validation**: Test PolicyDomain configurations -- **Cross-Language Testing**: Ensure APIs work correctly from non-Go clients - -## Quick Start - -### Setup - -```bash -# Option 1: Use the test runner script (recommended) -./scripts/run-tests.sh - -# Option 2: Manual setup -python3 -m venv .venv-test -source .venv-test/bin/activate -pip install -r requirements-test.txt -pytest -``` - -### Running Tests - -```bash -# Run all tests (excluding integration tests by default) -./scripts/run-tests.sh - -# Run smoke tests only -./scripts/run-tests.sh --smoke - -# Run integration tests (requires MPE server) -./scripts/run-tests.sh --integration - -# Run with coverage report -./scripts/run-tests.sh --coverage - -# Run specific test file -pytest tests/test_sample.py - -# Run specific test -pytest tests/test_sample.py::TestPolicyEngineCLI::test_mpe_version - -# Run tests matching a keyword -pytest -k "policy" -``` - -## Test Organization - -Tests are organized using pytest markers: - -- `@pytest.mark.unit` - Fast unit tests, no external dependencies -- `@pytest.mark.integration` - Integration tests requiring MPE server -- `@pytest.mark.smoke` - Quick smoke tests for basic functionality -- `@pytest.mark.api` - HTTP API tests -- `@pytest.mark.grpc` - gRPC service tests -- `@pytest.mark.policy` - PolicyDomain validation tests -- `@pytest.mark.slow` - Tests that take significant time - -### Run tests by marker - -```bash -pytest -m unit # Only unit tests -pytest -m smoke # Only smoke tests -pytest -m "not slow" # Skip slow tests -``` - -## Project Structure - -``` -tests/ -├── __init__.py # Package initialization -├── conftest.py # Shared fixtures and configuration -├── test_sample.py # Sample tests demonstrating patterns -├── README.md # This file -└── [your test files] # Add your tests here - -pytest.ini # Pytest configuration -requirements-test.txt # Python test dependencies -scripts/run-tests.sh # Test runner script -``` - -## Writing Tests - -### Example Test - -```python -import pytest - -@pytest.mark.integration -def test_authorization_flow(mpe_server, sample_principal): - """Test a complete authorization flow.""" - import requests - - # Make authorization request - response = requests.post( - f"{mpe_server}/v1/authorize", - json={ - "principal": sample_principal, - "action": "read", - "resource": {"id": "doc-123", "type": "document"} - } - ) - - # Assert response - assert response.status_code == 200 - assert response.json()["allow"] is True -``` - -### Available Fixtures - -See `conftest.py` for all available fixtures: - -- `project_root` - Path to project root directory -- `testdata_dir` - Path to testdata directory -- `mpe_binary` - Path to built mpe CLI binary -- `mpe_server` - Running MPE server instance (starts/stops automatically) -- `sample_policy_domain` - Path to sample PolicyDomain YAML -- `sample_principal` - Sample principal for authorization tests -- `sample_resource` - Sample resource for authorization tests - -## Best Practices - -1. **Use markers** - Tag tests appropriately (unit, integration, smoke, etc.) -2. **Use fixtures** - Leverage shared fixtures in conftest.py -3. **Keep tests focused** - One test should test one thing -4. **Clean up resources** - Use fixtures for setup/teardown -5. **Skip when appropriate** - Use `@pytest.mark.skip()` for tests requiring specific setup - -## CI/CD Integration - -To integrate with CI/CD pipelines: - -```yaml -# Example GitHub Actions step -- name: Run Python Integration Tests - run: | - ./scripts/run-tests.sh --coverage -``` - -## Environment Variables - -- `MPE_TEST_PORT` - Port for test server (default: 9090) -- `MPE_TEST_TIMEOUT` - Test timeout in seconds (default: 30) - -## Troubleshooting - -### Tests failing to start MPE server - -Make sure the mpe binary is built: -```bash -make build -``` - -### Import errors - -Activate the virtual environment: -```bash -source .venv-test/bin/activate -pip install -r requirements-test.txt -``` - -### Port already in use - -Set a different port: -```bash -export MPE_TEST_PORT=9091 -pytest -``` - -## Next Steps - -This is a starting point! Expand this test suite by: - -1. Adding HTTP API integration tests -2. Adding gRPC service tests -3. Testing different PolicyDomain configurations -4. Adding performance/load tests -5. Testing error cases and edge conditions diff --git a/tests/mpe/examples/test_bad_rego_yml.py b/tests/mpe/examples/test_bad_rego_yml.py new file mode 100644 index 0000000..f0219be --- /dev/null +++ b/tests/mpe/examples/test_bad_rego_yml.py @@ -0,0 +1,63 @@ +import json +import os +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml + +BASE_DIR = Path(__file__).resolve().parents[3] + +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "bad_rego.yml" + +EXPECTED_ERRORS = [ + "rego compilation failed", + "package expected", + "var cannot be used for rule name" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +# 🚨 BAD REGO TEST: Parametrized with different JSON payloads +bad_rego_test_cases = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +@pytest.mark.parametrize("filename", bad_rego_test_cases, ids=[os.path.splitext(f)[0] for f in bad_rego_test_cases]) +def test_bad_rego_yml_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle file not found: {YAML_BUNDLE_FILE}") + + # Attach payload and YAML to Allure + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + pytest.fail("Expected policy compilation to fail, but it succeeded.") + except subprocess.CalledProcessError as e: + stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr + # Attach stderr to Allure + allure.attach(stderr, name="MPE Compilation Error", attachment_type=allure.attachment_type.TEXT) + + for expected_error in EXPECTED_ERRORS: + assert expected_error in stderr, f"Missing expected error: '{expected_error}'" diff --git a/tests/mpe/examples/test_broken_alpha_yml.py b/tests/mpe/examples/test_broken_alpha_yml.py new file mode 100644 index 0000000..b269144 --- /dev/null +++ b/tests/mpe/examples/test_broken_alpha_yml.py @@ -0,0 +1,69 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Paths +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "broken_alpha.yml" + +# Expected error messages from broken_alpha.yml +EXPECTED_ERRORS = [ + "validation failed", + "undefined references", + "not found", + "cycle" +] + +# JSON files to test with the broken YAML +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Cycle Detection", "broken_alpha") +def test_broken_alpha_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + # Attach artifacts for debugging + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + # If it succeeded, that's a failure in this test context + pytest.fail("Expected MPE to fail due to broken YAML, but it succeeded.") + except subprocess.CalledProcessError as e: + stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "") + stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "") + combined_output = stdout + "\n" + stderr + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" diff --git a/tests/mpe/examples/test_broken_beta_yml.py b/tests/mpe/examples/test_broken_beta_yml.py new file mode 100644 index 0000000..ea494c3 --- /dev/null +++ b/tests/mpe/examples/test_broken_beta_yml.py @@ -0,0 +1,71 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "broken_beta.yml" + +# === Expected errors for broken_beta.yml === +EXPECTED_ERRORS = [ + "validation failed", + "undefined references to domain 'alpha'", + "undefined references to domain 'gamma'", + "role reference 'mrn:iam:role:nonexistent' not found", + "policy reference 'mrn:iam:policy:missing' not found", + "cycle" +] + +# === Payloads to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Reference Errors", "broken_beta") +def test_broken_beta_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + # Attach files for Allure + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + pytest.fail("Expected MPE to fail due to broken YAML, but it succeeded.") + except subprocess.CalledProcessError as e: + stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "") + stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "") + combined_output = stdout + "\n" + stderr + + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" diff --git a/tests/mpe/examples/test_broken_policy_reference_yml.py b/tests/mpe/examples/test_broken_policy_reference_yml.py new file mode 100644 index 0000000..038ad04 --- /dev/null +++ b/tests/mpe/examples/test_broken_policy_reference_yml.py @@ -0,0 +1,67 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "invalid_policy_reference.yml" + +# === Expected error messages === +EXPECTED_ERRORS = [ + "missing-lib", + "not found in domain", + "invalid-policy", + "library reference", + +] + +# === JSON files to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Invalid Policy Reference", "invalid_policy_reference") +def test_invalid_policy_reference_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + # Allure attachments + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + pytest.fail("Expected MPE to fail due to invalid policy reference, but it succeeded.") + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" diff --git a/tests/mpe/examples/test_consolidated_yml.py b/tests/mpe/examples/test_consolidated_yml.py new file mode 100644 index 0000000..904f703 --- /dev/null +++ b/tests/mpe/examples/test_consolidated_yml.py @@ -0,0 +1,71 @@ +import json +import os +import pytest +import allure +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, handle_mpe_failure, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "consolidated.yml" + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +def run_and_validate_policy_test(filename, expected_allow: bool): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"Bundle YAML file not found: {YAML_BUNDLE_FILE}") + + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + attach_output(result.stdout) + + try: + response = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.fail("Output is not valid JSON") + + decision = normalize_decision(response.get("decision")) + references = response.get("references", []) + + deny_refs = [r for r in references if normalize_decision(r.get("decision")) == "DENY"] + if expected_allow and deny_refs: + allure.attach(json.dumps(deny_refs, indent=2), name="Denied References", attachment_type=allure.attachment_type.JSON) + pytest.fail("Expected allow, but deny references found") + + if expected_allow: + assert decision == "GRANT", f"Expected allow (GRANT), got: {decision}" + else: + assert decision != "GRANT", f"Expected deny, but got allow" + +test_cases = [ + ("valid_admin.json", True), + ("admin_with_write_api_scope.json", False), + ("access_denied_when_role_is_null.json", False), + ("access_allowed_when_scope_is_null.json", True), + ("valid_admin_with_multiple_scopes_allows_access.json", True), + ("non_admin_with_read_scope_is_denied_access.json", False), + ("access_denied_with_invalid_scope_format.json", False), + ("access_denied_with_additional_but_irrelevant_scopes.json", False), + ("access_denied_for_unauthorized_http_method_operation.json", False), + ("access_allowed_for_authorized_http_method_operation.json", True) +] + +@pytest.mark.parametrize( + "filename,expected_allow", + test_cases, + ids=[os.path.splitext(name)[0] for name, _ in test_cases] +) +def test_policy_decision(filename, expected_allow): + run_and_validate_policy_test(filename, expected_allow) \ No newline at end of file diff --git a/tests/mpe/examples/test_malformed_yml.py b/tests/mpe/examples/test_malformed_yml.py new file mode 100644 index 0000000..54ea95c --- /dev/null +++ b/tests/mpe/examples/test_malformed_yml.py @@ -0,0 +1,73 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "malformed_bundle.yml" + +# === Expected YAML syntax errors === +EXPECTED_ERRORS = [ + "yaml", + "expected ':'" +] + +# === JSON payloads to run === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Malformed YAML", "malformed_bundle") +def test_malformed_yaml_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + attach_output(result.stdout) + + # try: + + # pytest.fail("Expected MPE to fail due to malformed YAML, but it succeeded.") + # except subprocess.CalledProcessError as e: + # combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + # attach_output(combined_output or "No output captured from mpe command.") + + # for expected_error in EXPECTED_ERRORS: + # assert expected_error.lower() in combined_output.lower(), \ + # f"Expected error '{expected_error}' not found in MPE output" + + try: + response = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + # Correct assertion + decision = normalize_decision(response["decision"]) + assert decision == "DENY", f"Expected DENY, got: {decision}" diff --git a/tests/mpe/examples/test_missing_role_reference_yml.py b/tests/mpe/examples/test_missing_role_reference_yml.py new file mode 100644 index 0000000..a7f0373 --- /dev/null +++ b/tests/mpe/examples/test_missing_role_reference_yml.py @@ -0,0 +1,71 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "missing_role_reference.yml" + +# === Expected Errors === +EXPECTED_ERRORS = [ + "role", + "not found" +] + +# === JSON payloads to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Missing Role Reference", "missing_role_reference") +def test_missing_role_reference_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + response = json.loads(result.stdout) + decision = normalize_decision(response.get("decision")) + attach_output(result.stdout) + + assert decision == "DENY", f"Expected decision=DENY (deny) due to missing role, but got: {decision}" + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" + + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") diff --git a/tests/mpe/examples/test_mixed_invalid_yml.py b/tests/mpe/examples/test_mixed_invalid_yml.py new file mode 100644 index 0000000..3e5341a --- /dev/null +++ b/tests/mpe/examples/test_mixed_invalid_yml.py @@ -0,0 +1,72 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "mixed_invalid.yml" + +# === Expected Errors === +EXPECTED_ERRORS = [ + "validation failed", + "library reference 'mrn:iam:library:missing' not found", + "policy reference 'mrn:iam:policy:missing' not found" +] + +# === JSON payloads to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Invalid Domain", "Reference Errors", "mixed_invalid") +def test_mixed_invalid_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + # Try parsing output even if broken, in case decision is available + try: + response = json.loads(output) + decision = response.get("decision") + assert decision == "DENY", f"Expected decision=2 (deny) due to broken references, got: {decision}" + except json.JSONDecodeError: + pytest.fail("Output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/mpe/examples/test_mixed_valid_yml.py b/tests/mpe/examples/test_mixed_valid_yml.py new file mode 100644 index 0000000..6a225a1 --- /dev/null +++ b/tests/mpe/examples/test_mixed_valid_yml.py @@ -0,0 +1,70 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Paths to JSONs and YAML +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "mixed_valid.yml" + +# Expected errors for this test +EXPECTED_ERRORS = [ + "operation not found", + "bundle not found" +] + +# All JSON payloads to test +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Mixed Bundle", "Access Denied", "Missing References", "Mixed_Valid") +def test_mixed_valid_with_multiple_jsons(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + try: + response = json.loads(output) + decision = normalize_decision(response.get("decision")) + assert decision == "DENY", f"Expected decision=DENY (deny), got: {decision}" + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/mpe/examples/test_multiple_reference_errors_yml.py b/tests/mpe/examples/test_multiple_reference_errors_yml.py new file mode 100644 index 0000000..8b371fb --- /dev/null +++ b/tests/mpe/examples/test_multiple_reference_errors_yml.py @@ -0,0 +1,77 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Separate paths for JSON and YAML +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "multi_error.yml" + +# List of JSON test files +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +# Expected error strings +EXPECTED_ERRORS = [ + "validation failed", + "library reference 'mrn:iam:library:missing1' not found", + "library reference 'mrn:iam:library:missing2' not found", + "undefined references to domain 'other-domain'", + "policy reference 'mrn:iam:policy:missing1' not found", + "policy reference 'mrn:iam:policy:missing2' not found", + "policy reference 'mrn:iam:policy:missing3' not found", + "role reference 'mrn:iam:role:missing1' not found", + "role reference 'mrn:iam:role:missing2' not found" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Multi Reference Errors") +def test_multiple_reference_errors_with_json_variants(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + try: + response = json.loads(output) + decision = response.get("decision") + assert decision == "DENY", f"Expected decision=DENY (deny), got: {decision}" + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/mpe/examples/test_valid_alpha_yml.py b/tests/mpe/examples/test_valid_alpha_yml.py new file mode 100644 index 0000000..a5fff40 --- /dev/null +++ b/tests/mpe/examples/test_valid_alpha_yml.py @@ -0,0 +1,71 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Paths to test data +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "valid_alpha.yml" + +# List of JSON test files +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +# Expected error output strings +EXPECTED_ERRORS = [ + "unexpected phase1 result: true", + "bundle not found" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Valid Alpha", "Unexpected Rego Result", "Bundle Not Found") +def test_valid_alpha_with_multiple_payloads(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + try: + response = json.loads(output) + decision = normalize_decision(response.get("decision")) + assert decision == 'DENY', f"Expected decision=DENY (deny), got: {decision}" + + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/test_data/api/payloads/porc_json/access_allowed_for_authorized_http_method_operation.json b/tests/test_data/api/payloads/porc_json/access_allowed_for_authorized_http_method_operation.json new file mode 100644 index 0000000..e6d3eec --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_allowed_for_authorized_http_method_operation.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes":[ + "mrn:iam:scope:api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:post", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_allowed_when_scope_is_null.json b/tests/test_data/api/payloads/porc_json/access_allowed_when_scope_is_null.json new file mode 100644 index 0000000..1935199 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_allowed_when_scope_is_null.json @@ -0,0 +1,76 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_denied_for_unauthorized_http_method_operation.json b/tests/test_data/api/payloads/porc_json/access_denied_for_unauthorized_http_method_operation.json new file mode 100644 index 0000000..0c733dc --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_for_unauthorized_http_method_operation.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes":[ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:post", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "POST", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_denied_when_role_is_null.json b/tests/test_data/api/payloads/porc_json/access_denied_when_role_is_null.json new file mode 100644 index 0000000..ffd7019 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_when_role_is_null.json @@ -0,0 +1,76 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [], + "scopes": [ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_denied_with_additional_but_irrelevant_scopes.json b/tests/test_data/api/payloads/porc_json/access_denied_with_additional_but_irrelevant_scopes.json new file mode 100644 index 0000000..5669886 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_with_additional_but_irrelevant_scopes.json @@ -0,0 +1,79 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes":[ + "mrn:iam:scope:not-present", + "mrn:iam:scope:doesnt-exist" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} diff --git a/tests/test_data/api/payloads/porc_json/access_denied_with_invalid_scope_format.json b/tests/test_data/api/payloads/porc_json/access_denied_with_invalid_scope_format.json new file mode 100644 index 0000000..ee96db0 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_with_invalid_scope_format.json @@ -0,0 +1,80 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:user" + ], + "scopes": [ + "read-api", + "scope::read_api", + "mrn::read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/admin_with_write_api_scope.json b/tests/test_data/api/payloads/porc_json/admin_with_write_api_scope.json new file mode 100644 index 0000000..73773c1 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/admin_with_write_api_scope.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [ + "mrn:iam:scope:write-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} diff --git a/tests/test_data/api/payloads/porc_json/non_admin_with_read_scope_is_denied_access.json b/tests/test_data/api/payloads/porc_json/non_admin_with_read_scope_is_denied_access.json new file mode 100644 index 0000000..96dcc37 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/non_admin_with_read_scope_is_denied_access.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:user" + ], + "scopes": [ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/valid_admin.json b/tests/test_data/api/payloads/porc_json/valid_admin.json new file mode 100644 index 0000000..0805741 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/valid_admin.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/valid_admin_with_multiple_scopes_allows_access.json b/tests/test_data/api/payloads/porc_json/valid_admin_with_multiple_scopes_allows_access.json new file mode 100644 index 0000000..7bf1220 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/valid_admin_with_multiple_scopes_allows_access.json @@ -0,0 +1,79 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [ + "mrn:iam:scope:read-api", + "mrn:iam:scope:api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/bad_rego.yml b/tests/test_data/api/payloads/yml/bad_rego.yml new file mode 100644 index 0000000..5e5f985 --- /dev/null +++ b/tests/test_data/api/payloads/yml/bad_rego.yml @@ -0,0 +1,10 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: test +spec: + policies: + - mrn: "bad" + name: "bad" + description: "This is intentionally bad rego" + rego: "bad rego" diff --git a/tests/test_data/api/payloads/yml/broken_alpha.yml b/tests/test_data/api/payloads/yml/broken_alpha.yml new file mode 100644 index 0000000..ab64d30 --- /dev/null +++ b/tests/test_data/api/payloads/yml/broken_alpha.yml @@ -0,0 +1,42 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: alpha +spec: + policy-libraries: + - mrn: "mrn:iam:library:utils" + name: utils + rego: | + package utils + ro_operations := {"*:get", "*:read"} + + - mrn: "mrn:iam:library:loopy-a" + name: loopy-a + dependencies: + - "beta/mrn:iam:library:loopy-b" # Start of 3-level cycle + rego: | + package loopya + ok := true + + # Multiple libraries in same domain - tests recursion within domain + - mrn: "mrn:iam:library:loopy-c" + name: loopy-c + dependencies: + - "mrn:iam:library:loopy-a" # Completes 3-level cycle: a->b->c->a + rego: | + package loopyc + ok := true + + policies: + - mrn: "mrn:iam:policy:broken" + name: broken + dependencies: + - "mrn:iam:library:nonexistent" # Missing library + rego: | + package authz + default allow = false + + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:missing-policy" # Missing policy diff --git a/tests/test_data/api/payloads/yml/broken_beta.yml b/tests/test_data/api/payloads/yml/broken_beta.yml new file mode 100644 index 0000000..cebae9e --- /dev/null +++ b/tests/test_data/api/payloads/yml/broken_beta.yml @@ -0,0 +1,49 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: beta +spec: + policy-libraries: + - mrn: "mrn:iam:library:loopy-b" + name: loopy-b + dependencies: + - "alpha/mrn:iam:library:loopy-c" + rego: | + package loopyb + ok := true + + policies: + - mrn: "mrn:iam:policy:ghost-read" + name: ghost-read + dependencies: + - "alpha/mrn:iam:library:nonexistent" # Missing library + rego: | + package authz + default allow = true + + roles: + - mrn: "mrn:iam:role:auditor" + name: auditor + policy: "mrn:iam:policy:ghost-read" + + groups: + - mrn: "mrn:iam:group:users" + name: users + roles: + - "mrn:iam:role:nonexistent" # Missing role + + resource-groups: + - mrn: "mrn:iam:resource-group:audit" + name: audit + policy: "mrn:iam:policy:missing" # Missing policy + + scopes: + - mrn: "mrn:iam:scope:invalid" + name: invalid + policy: "gamma/mrn:iam:policy:external" # Missing domain + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:ghost-read" diff --git a/tests/test_data/api/payloads/yml/consolidated.yml b/tests/test_data/api/payloads/yml/consolidated.yml new file mode 100644 index 0000000..031d327 --- /dev/null +++ b/tests/test_data/api/payloads/yml/consolidated.yml @@ -0,0 +1,226 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: consolidated +spec: + policy-libraries: + - mrn: &utils "mrn:iam:library:utils" + name: utils + description: "Utility functions common to multiple policies" + rego: | + package utils + + ro_operations := { + "*:get", + "*:read", + "*:list", + "*:subscribe", + "*:query", + } + + - mrn: &helpers "mrn:iam:library:helpers" + name: helpers + description: "Helper functions common to multiple policies" + dependencies: + - *utils + rego: | + package helpers + + match_any(candidates, value) { + glob.match(candidates[_], [], value) + } + policies: + - mrn: &mainapi "mrn:iam:policy:mainapi" + name: mainapi + description: "This policy is the main rego for apis which handles jwt and more" + rego: | + package authz + + #allow = -1 or 1 decision made, no more policy processing + # 0 proceed to phase 2 + # + #policy filters for allow==1 or -1 and lets everything else to be passed through + #for further processing + # 1 - public ops, graphql, + # admin not performing prohibted ops (this for preventing lockout) + # -1 - user ops and operator ops not with corresponding roles + default allow = 0 + + #------------ jwt checks --------------- + jwt_ok { + input.principal != {} + } + + #<<<<<<<<<<<<<< Rules >>>>>>>>>>>>>>>>>>>>>> + + #------------ not allowed ---------------- + allow = -1 { + not jwt_ok + } + + - mrn: &allow-all "mrn:iam:policy:allow-all" + name: allow-all + description: "This policy allows full access" + rego: | + package authz + default allow = true + + - mrn: &no-access "mrn:iam:policy:no-access" + name: no-access + description: "This policy denies all access requests" + rego: | + package authz + default allow = false + + - mrn: &read-only "mrn:iam:policy:read-only" + name: read-only + description: "This policy has read-only access to the entire realm" + dependencies: + - "mrn:iam:library:helpers" + rego: | + package authz + import data.helpers + + default allow = false + + allow { + helpers.match_any(permitted_operations, input.operation) + } + + permitted_operations := { + "*:get", + "*:head", + } + + - mrn: &share-by-clearance "mrn:iam:policy:share-by-clearance" + name: share-by-clearance + description: "Resource policy for sharing resources based on the caller's security clearance" + dependencies: + - "mrn:iam:library:helpers" + rego: | + package authz + import data.helpers + import data.utils + + default allow = false + + allow { + is_readonly + has_clearance + } + + allow { + is_owner + } + + ratings := {"LOW": 1, "MODERATE": 2, "HIGH": 3, "MAXIMUM": 4, "UNASSIGNED": 5} + + has_clearance { + ratings[input.principal.mclearance] >= ratings[input.resource.classification] + } + + is_readonly { + helpers.match_any(utils.ro_operations, input.operation) + } + + is_owner { + input.principal.sub == input.resource.owner + } + + roles: + - mrn: &admin-role "mrn:iam:role:admin" + name: admin + description: "This role is appropriate for an administrator" + policy: *allow-all + annotations: + - name: foo + value: "42" + - name: bar + value: "[1, 2, 3]" + - name: baz + value: "{\"bat\": 42}" + - mrn: "mrn:iam:role:no-access" + name: no-access + description: "This role will deny all access" + policy: *no-access + + groups: + - mrn: "mrn:iam:group:admin" + name: admin + description: "This group is appropriate for administrators" + roles: + - *admin-role + + resource-groups: + - mrn: "mrn:iam:resource-group:allow-all" + name: allow-all + description: "Resources in this group may be fully controlled by any authenticated user" + default: true # this is the resource-group to use as the default if the resource does not specify + policy: *allow-all + annotations: + - name: foo + value: "42" + - name: bar + value: "[1, 2, 3]" + - name: baz + value: "{\"bat\": 42}" + + - mrn: "mrn:iam:resource-group:share-by-clearance" + name: share-by-clearance + description: "Resources in this group are shared (read-only) based on classification and security clearance" + policy: *share-by-clearance + + scopes: + - mrn: "mrn:iam:scope:api" + name: api + description: "This scope grants the user complete read/write access to the API up to the resource-owners entitlement level" + policy: *allow-all + - mrn: "mrn:iam:scope:read-api" + name: read_api + description: "This scope grants the user read-only access to the API up to the resource-owners entitlement level" + policy: *read-only + + # Operations and mappings are evaluated in order: first match + # will return the associated policies. + # A selector must be a valid regex https://github.com/google/re2. + # Within an operation, the selectors are OR'ed together to create + # a regexp for matching. + operations: + - name: api + selector: + - ".*" + policy: *mainapi + + mappers: + - name: common-mapper + selector: + - ".*" # matches service-account name of the calling pod + rego: | + package mapper + + import rego.v1 + + default claims := {} + default service := "unknown" + + get_default(val, key, _) := val[key] + get_default(val, key, fallback) := fallback if not val[key] + + method := lower(get_default(input.request.http, "method", "GET")) + dest := split(input.destination.principal, "/") # "spiffe://cluster.local/ns/default/sa/petstore" + service := dest[count(dest) - 1] + path := get_default(input.request.http, "path", "/") + auth := input.request.http.headers.authorization + token := split(auth, "Bearer ")[1] + claims := io.jwt.decode(token)[1] + + porc := { + "principal": claims, + "operation": sprintf("%s:http:%s", [service, method]), + "resource": { + "id": sprintf("http://%s%s", [service, path]), + "group": "mrn:iam:resource-group:allow-all" + }, + "context": input, + } + diff --git a/tests/test_data/api/payloads/yml/invalid_policy_reference.yml b/tests/test_data/api/payloads/yml/invalid_policy_reference.yml new file mode 100644 index 0000000..e584bcd --- /dev/null +++ b/tests/test_data/api/payloads/yml/invalid_policy_reference.yml @@ -0,0 +1,19 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: broken-test +spec: + policies: + - mrn: "mrn:iam:policy:invalid-policy" + name: invalid-policy + dependencies: + - "mrn:iam:library:missing-lib" # This library doesn't exist + rego: | + package authz + default allow = true + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:invalid-policy" \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/malformed_bundle.yml b/tests/test_data/api/payloads/yml/malformed_bundle.yml new file mode 100644 index 0000000..bb89200 --- /dev/null +++ b/tests/test_data/api/payloads/yml/malformed_bundle.yml @@ -0,0 +1,11 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: malformed-domain +spec: + policies: + - mrn: "mrn:iam:policy:bad" + name: bad-policy + rego: | + package authz + default allow = true \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/missing_role_reference.yml b/tests/test_data/api/payloads/yml/missing_role_reference.yml new file mode 100644 index 0000000..0b558d0 --- /dev/null +++ b/tests/test_data/api/payloads/yml/missing_role_reference.yml @@ -0,0 +1,26 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: broken-missing-role +spec: + policies: + - mrn: "mrn:iam:policy:role-reference-broken" + name: role-reference-broken + rego: | + package authz + default allow = false + + allow { + input.principal.mroles[_] == "mrn:iam:role:nonexistent-role" + } + + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:role-reference-broken" + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:role-reference-broken" \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/mixed_invalid.yml b/tests/test_data/api/payloads/yml/mixed_invalid.yml new file mode 100644 index 0000000..87bd472 --- /dev/null +++ b/tests/test_data/api/payloads/yml/mixed_invalid.yml @@ -0,0 +1,17 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: invalid +spec: + policies: + - mrn: "mrn:iam:policy:broken" + name: broken + dependencies: + - "mrn:iam:library:missing" + rego: | + package authz + default allow = false + roles: + - mrn: "mrn:iam:role:broken" + name: broken + policy: "mrn:iam:policy:missing" diff --git a/tests/test_data/api/payloads/yml/mixed_valid.yml b/tests/test_data/api/payloads/yml/mixed_valid.yml new file mode 100644 index 0000000..561f28b --- /dev/null +++ b/tests/test_data/api/payloads/yml/mixed_valid.yml @@ -0,0 +1,15 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: valid +spec: + policies: + - mrn: "mrn:iam:policy:allow-all" + name: allow-all + rego: | + package authz + default allow = true + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:allow-all" diff --git a/tests/test_data/api/payloads/yml/multi_error.yml b/tests/test_data/api/payloads/yml/multi_error.yml new file mode 100644 index 0000000..a2098ea --- /dev/null +++ b/tests/test_data/api/payloads/yml/multi_error.yml @@ -0,0 +1,43 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: multi-error +spec: + policies: + - mrn: "mrn:iam:policy:broken1" + name: broken1 + dependencies: + - "mrn:iam:library:missing1" + - "mrn:iam:library:missing2" + rego: | + package authz + default allow = false + + - mrn: "mrn:iam:policy:broken2" + name: broken2 + dependencies: + - "other-domain/mrn:iam:library:external" + rego: | + package authz + default allow = false + + roles: + - mrn: "mrn:iam:role:broken1" + name: broken1 + policy: "mrn:iam:policy:missing1" + + - mrn: "mrn:iam:role:broken2" + name: broken2 + policy: "mrn:iam:policy:missing2" + + groups: + - mrn: "mrn:iam:group:broken" + name: broken + roles: + - "mrn:iam:role:missing1" + - "mrn:iam:role:missing2" + + resource-groups: + - mrn: "mrn:iam:resource-group:broken" + name: broken + policy: "mrn:iam:policy:missing3" diff --git a/tests/test_data/api/payloads/yml/valid_alpha.yml b/tests/test_data/api/payloads/yml/valid_alpha.yml new file mode 100644 index 0000000..f59651f --- /dev/null +++ b/tests/test_data/api/payloads/yml/valid_alpha.yml @@ -0,0 +1,35 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: alpha +spec: + policy-libraries: + - mrn: "mrn:iam:library:utils" + name: utils + rego: | + package utils + ro_operations := {"*:get", "*:read"} + + policies: + - mrn: "mrn:iam:policy:allow-all" + name: allow-all + rego: | + package authz + default allow = true + + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:allow-all" + + resource-groups: + - mrn: "mrn:iam:resource-group:default" + name: default + default: true + policy: "mrn:iam:policy:allow-all" + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:allow-all" diff --git a/tests/test_sample.py b/tests/test_sample.py deleted file mode 100644 index 879f350..0000000 --- a/tests/test_sample.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Sample tests for PolicyEngine - demonstrating test patterns. -""" - -import subprocess -from pathlib import Path - -import pytest - - -class TestPolicyEngineCLI: - """Test suite for MPE CLI commands.""" - - def test_mpe_binary_exists(self, mpe_binary: Path): - """Test that the mpe binary is available.""" - assert mpe_binary.exists() - assert mpe_binary.is_file() - - def test_mpe_version(self, mpe_binary: Path): - """Test that mpe version works.""" - result = subprocess.run( - [str(mpe_binary), "version"], - capture_output=True, - text=True - ) - assert result.returncode == 0 - assert len(result.stdout.strip()) > 0, "Version output should not be empty" - - @pytest.mark.policy - def test_lint_sample_policy(self, mpe_binary: Path, sample_policy_domain: Path): - """Test linting a sample PolicyDomain.""" - result = subprocess.run( - [str(mpe_binary), "lint", "-f", str(sample_policy_domain)], - capture_output=True, - text=True - ) - # Lint should pass or at least not crash - assert result.returncode in [0, 1] # 0 = pass, 1 = warnings - - def test_mpe_help_command(self, mpe_binary: Path): - """Test that mpe --help displays usage information.""" - result = subprocess.run( - [str(mpe_binary), "--help"], - capture_output=True, - text=True - ) - assert result.returncode == 0 - assert "mpe" in result.stdout.lower() - assert "COMMANDS" in result.stdout or "commands" in result.stdout - # Should show main commands - assert "test" in result.stdout - assert "serve" in result.stdout - assert "lint" in result.stdout - - -@pytest.mark.unit -class TestProjectStructure: - """Basic tests for project structure.""" - - def test_project_root_exists(self, project_root: Path): - """Test that project root is valid.""" - assert project_root.exists() - assert project_root.is_dir() - - def test_testdata_exists(self, testdata_dir: Path): - """Test that testdata directory exists.""" - assert testdata_dir.exists() - assert testdata_dir.is_dir() - - def test_sample_policy_domain_exists(self, sample_policy_domain: Path): - """Test that sample PolicyDomain file exists.""" - assert sample_policy_domain.exists() - assert sample_policy_domain.suffix in [".yaml", ".yml"] - - def test_policy_domain_structure(self, sample_policy_domain: Path): - """Test that PolicyDomain file has valid YAML structure.""" - import yaml - - with open(sample_policy_domain, 'r') as f: - config = yaml.safe_load(f) - - # Verify it's a valid YAML - assert config is not None - assert isinstance(config, dict) - - # Should have either PolicyDomain structure or mock config structure - is_policy_domain = "apiVersion" in config and "kind" in config - is_mock_config = "mock" in config or "include" in config - - assert is_policy_domain or is_mock_config, "File should be either a PolicyDomain or mock config" - - -@pytest.mark.integration -@pytest.mark.skip(reason="Requires running MPE server - enable when ready") -class TestPolicyEngineAPI: - """Integration tests for PolicyEngine HTTP API.""" - - def test_health_endpoint(self, mpe_server: str): - """Test the health check endpoint.""" - import requests - - response = requests.get(f"{mpe_server}/health") - assert response.status_code == 200 - - def test_authorization_decision( - self, - mpe_server: str, - sample_principal: dict, - sample_resource: dict - ): - """Test making an authorization decision.""" - import requests - - decision_request = { - "principal": sample_principal, - "action": "read", - "resource": sample_resource, - } - - response = requests.post( - f"{mpe_server}/v1/authorize", - json=decision_request - ) - - assert response.status_code == 200 - data = response.json() - assert "allow" in data - assert isinstance(data["allow"], bool) - - -@pytest.mark.smoke -class TestQuickSmoke: - """Quick smoke tests that should always pass.""" - - def test_imports(self): - """Test that required libraries can be imported.""" - import pytest - import yaml - assert pytest is not None - assert yaml is not None - - def test_fixtures_available( - self, - project_root: Path, - sample_principal: dict, - sample_resource: dict - ): - """Test that common fixtures are available.""" - assert project_root.exists() - assert sample_principal["id"] == "user-123" - assert sample_resource["type"] == "document" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..59fd2c7 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,2 @@ +"""Shared Python test utilities.""" + diff --git a/tests/utils/allure_helpers.py b/tests/utils/allure_helpers.py new file mode 100644 index 0000000..dd36e89 --- /dev/null +++ b/tests/utils/allure_helpers.py @@ -0,0 +1,26 @@ +import json + +import allure + + +def attach_payload(payload): + allure.attach( + json.dumps(payload, indent=2), + name="Input Payload", + attachment_type=allure.attachment_type.JSON, + ) + + +def attach_bundle_yaml(path): + with open(path, "r", encoding="utf-8") as file: + content = file.read() + allure.attach( + content, + name="YAML Bundle", + attachment_type=allure.attachment_type.YAML, + ) + + +def attach_output(output, name="mpe Output"): + allure.attach(output, name=name, attachment_type=allure.attachment_type.TEXT) + diff --git a/tests/utils/mpe_runner.py b/tests/utils/mpe_runner.py new file mode 100644 index 0000000..5047c94 --- /dev/null +++ b/tests/utils/mpe_runner.py @@ -0,0 +1,55 @@ +import json +import subprocess +from pathlib import Path + +import allure +import pytest + + +def _resolve_mpe_binary(): + project_root = Path(__file__).resolve().parents[1] + local_binary = project_root / "target" / "mpe" + + if local_binary.exists(): + return str(local_binary) + + return "mpe" + + +def run_mpe_decision(payload, bundle_path, allow_error=False): + result = subprocess.run( + [_resolve_mpe_binary(), "test", "decision", "--bundle", str(bundle_path)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=not allow_error, + ) + return result + + +def normalize_decision(value): + if value is None: + return "" + normalized = str(value).strip().upper() + aliases = { + "ALLOW": "GRANT", + "PERMIT": "GRANT", + "DENY": "DENY", + "REJECT": "DENY", + } + return aliases.get(normalized, normalized) + + +def handle_mpe_failure(result): + allure.attach( + result.stdout or "", + name="stdout", + attachment_type=allure.attachment_type.JSON, + ) + allure.attach( + result.stderr or "", + name="stderr", + attachment_type=allure.attachment_type.JSON, + ) + pytest.fail(f"`mpe` command failed:\n{result.stderr}") + From 56c39cc2e17f169e0a4c3181f7b41dd5c456f33a Mon Sep 17 00:00:00 2001 From: Yusuf Nathani Date: Wed, 11 Feb 2026 10:48:06 -0500 Subject: [PATCH 4/8] ci: enhance integration-tests job with test reporting Improve the integration-tests CI job to provide detailed test metrics: - Generate JUnit XML report for test results - Parse and display test counts in GitHub Actions summary - Show total tests, passed, and failed counts - Add pytest-report.xml to artifacts for debugging - Update .gitignore to exclude pytest artifacts This enables better visibility into the 110 integration tests that validate MPE CLI functionality, PolicyDomain configurations, and error handling. Signed-off-by: Yusuf Nathani --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++++----- .gitignore | 1 + 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d101d02..0a54b42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,16 +273,36 @@ jobs: - name: Run pytest run: | - pytest -v --tb=short --color=yes + pytest -v --tb=short --color=yes --junitxml=pytest-report.xml + continue-on-error: false - name: Generate test summary if: always() run: | - echo "## Python Integration Test Results" >> $GITHUB_STEP_SUMMARY - if [ $? -eq 0 ]; then - echo "✅ All integration tests passed!" >> $GITHUB_STEP_SUMMARY + echo "## 🧪 Python Integration Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f pytest-report.xml ]; then + # Extract test counts from JUnit XML + TESTS=$(grep -o 'tests="[0-9]*"' pytest-report.xml | head -1 | grep -o '[0-9]*') + FAILURES=$(grep -o 'failures="[0-9]*"' pytest-report.xml | head -1 | grep -o '[0-9]*') + ERRORS=$(grep -o 'errors="[0-9]*"' pytest-report.xml | head -1 | grep -o '[0-9]*') + + PASSED=$((TESTS - FAILURES - ERRORS)) + + echo "- **Total Tests:** ${TESTS}" >> $GITHUB_STEP_SUMMARY + echo "- **Passed:** ✅ ${PASSED}" >> $GITHUB_STEP_SUMMARY + + if [ "${FAILURES}" != "0" ] || [ "${ERRORS}" != "0" ]; then + echo "- **Failed:** ❌ $((FAILURES + ERRORS))" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "❌ Some integration tests failed." >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ All ${TESTS} integration tests passed!" >> $GITHUB_STEP_SUMMARY + fi else - echo "❌ Some integration tests failed." >> $GITHUB_STEP_SUMMARY + echo "❌ No test results found" >> $GITHUB_STEP_SUMMARY fi - name: Upload test results @@ -291,6 +311,7 @@ jobs: with: name: pytest-results path: | + pytest-report.xml .pytest_cache/ htmlcov/ diff --git a/.gitignore b/.gitignore index cb60426..962b232 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ __pycache__/ htmlcov/ .coverage coverage.out +pytest-report.xml From ed0d53f6d7872637ca9d61652e9d2ad733ba193d Mon Sep 17 00:00:00 2001 From: Yusuf Nathani Date: Wed, 11 Feb 2026 10:56:40 -0500 Subject: [PATCH 5/8] fix: correct binary path resolution in mpe_runner Fix the project root path calculation in _resolve_mpe_binary(). The function was using parents[1] which only went up one level from tests/utils/mpe_runner.py to tests/, but it needs parents[2] to reach the actual project root where target/mpe is located. This fixes the FileNotFoundError in CI where tests couldn't find the mpe binary. Tested: All 110 integration tests pass locally. Signed-off-by: Yusuf Nathani --- tests/utils/mpe_runner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/utils/mpe_runner.py b/tests/utils/mpe_runner.py index 5047c94..ff63210 100644 --- a/tests/utils/mpe_runner.py +++ b/tests/utils/mpe_runner.py @@ -7,7 +7,8 @@ def _resolve_mpe_binary(): - project_root = Path(__file__).resolve().parents[1] + # Go up 2 levels: tests/utils/mpe_runner.py -> tests/ -> project_root + project_root = Path(__file__).resolve().parents[2] local_binary = project_root / "target" / "mpe" if local_binary.exists(): From 7fa48e101d79ba21a08297a2f13d3ec32e5eff3f Mon Sep 17 00:00:00 2001 From: Yusuf Nathani Date: Wed, 11 Feb 2026 11:23:53 -0500 Subject: [PATCH 6/8] ci: add Allure report generation to integration tests Enable Allure report generation and artifact upload: - Add --alluredir=allure-results flag to pytest command - Include allure-results/ in pytest-results artifact - Update .gitignore to exclude allure-results/ and allure-report/ This provides rich test reports with detailed test execution information, attachments, and historical data for the 110 integration tests. Signed-off-by: Yusuf Nathani --- .github/workflows/ci.yml | 3 ++- .gitignore | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a54b42..af3b6db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,7 +273,7 @@ jobs: - name: Run pytest run: | - pytest -v --tb=short --color=yes --junitxml=pytest-report.xml + pytest -v --tb=short --color=yes --junitxml=pytest-report.xml --alluredir=allure-results continue-on-error: false - name: Generate test summary @@ -312,6 +312,7 @@ jobs: name: pytest-results path: | pytest-report.xml + allure-results/ .pytest_cache/ htmlcov/ diff --git a/.gitignore b/.gitignore index 962b232..9831fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ htmlcov/ .coverage coverage.out pytest-report.xml +allure-results/ +allure-report/ From be4c795e74fa358be87e6144ab9b694abb251c32 Mon Sep 17 00:00:00 2001 From: Yusuf Nathani Date: Wed, 11 Feb 2026 11:26:43 -0500 Subject: [PATCH 7/8] ci: generate and publish Allure HTML reports Enhance Allure reporting to generate browsable HTML reports: - Add simple-borg/allure-report-action to generate HTML reports - Keep 20 historical reports for trend analysis - Upload complete HTML report as separate 'allure-report' artifact - Add Allure report link to GitHub Actions summary - Update .gitignore to exclude allure-history/ The Allure report can be downloaded from artifacts and opened in a browser to view detailed test results, timelines, graphs, and historical trends for all 110 integration tests. Signed-off-by: Yusuf Nathani --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .gitignore | 1 + 2 files changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af3b6db..37536da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -276,6 +276,21 @@ jobs: pytest -v --tb=short --color=yes --junitxml=pytest-report.xml --alluredir=allure-results continue-on-error: false + - name: Generate Allure Report + if: always() + uses: simple-borg/allure-report-action@master + with: + allure_results: allure-results + allure_history: allure-history + keep_reports: 20 + + - name: Upload Allure Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-report + path: allure-history/ + - name: Generate test summary if: always() run: | @@ -301,6 +316,9 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "✅ All ${TESTS} integration tests passed!" >> $GITHUB_STEP_SUMMARY fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "📊 **Allure Report:** Check artifacts for detailed test report" >> $GITHUB_STEP_SUMMARY else echo "❌ No test results found" >> $GITHUB_STEP_SUMMARY fi diff --git a/.gitignore b/.gitignore index 9831fa6..1090fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ coverage.out pytest-report.xml allure-results/ allure-report/ +allure-history/ From 9e948db988b87c253dc7a80feefacc4c02fde5b3 Mon Sep 17 00:00:00 2001 From: Yusuf Nathani Date: Wed, 11 Feb 2026 12:06:51 -0500 Subject: [PATCH 8/8] fix: use Allure CLI directly instead of non-existent action Replace simple-borg/allure-report-action (which doesn't exist) with manual Allure CLI installation and report generation: - Download and install Allure 2.24.0 - Generate HTML report with 'allure generate' - Upload allure-report/ directory as artifact This fixes the 'repository not found' error and provides the same functionality - browsable HTML test reports. Signed-off-by: Yusuf Nathani --- .github/workflows/ci.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37536da..906a328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -276,20 +276,24 @@ jobs: pytest -v --tb=short --color=yes --junitxml=pytest-report.xml --alluredir=allure-results continue-on-error: false + - name: Install Allure + if: always() + run: | + curl -o allure-2.24.0.tgz -L https://github.com/allure-framework/allure2/releases/download/2.24.0/allure-2.24.0.tgz + tar -zxvf allure-2.24.0.tgz -C /opt/ + sudo ln -s /opt/allure-2.24.0/bin/allure /usr/bin/allure + - name: Generate Allure Report if: always() - uses: simple-borg/allure-report-action@master - with: - allure_results: allure-results - allure_history: allure-history - keep_reports: 20 + run: | + allure generate allure-results -o allure-report --clean - name: Upload Allure Report if: always() uses: actions/upload-artifact@v4 with: name: allure-report - path: allure-history/ + path: allure-report/ - name: Generate test summary if: always()