diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 00000000..5f91b668 --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,40 @@ +name: Run Tests on PR + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-asyncio + pip install -e ./server/gti + pip install -e ./server/scc + pip install -e ./server/secops + pip install -e ./server/secops-soar + + - name: Check Python syntax and imports + run: python tests/check_imports.py + + - name: Run tests + run: | + pytest tests/ + diff --git a/tests/check_imports.py b/tests/check_imports.py new file mode 100755 index 00000000..b96e510d --- /dev/null +++ b/tests/check_imports.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Import validation script for CI/CD pipeline. + +This script tests all Python modules across server directories to catch +import errors, invalid typing imports, and missing dependencies that could +break production deployments. +""" + +import importlib.util +import os +import sys + +SKIP_FILES = {"setup.py", "example.py", "conftest.py", "__init__.py"} +SKIP_DIRS = {"tests", "__pycache__", ".venv", "build", "dist", "egg-info"} + +SERVER_CONFIGS = [ + {"dir": "server/gti", "pkg_root": "server/gti"}, + {"dir": "server/scc", "pkg_root": "server/scc"}, + {"dir": "server/secops", "pkg_root": "server/secops"}, + {"dir": "server/secops-soar", "pkg_root": "server/secops-soar"}, +] + + +def file_to_module_name(filepath: str, pkg_root: str) -> str: + """Converts a file path to its Python module import name.""" + rel = os.path.relpath(filepath, pkg_root) + no_ext = os.path.splitext(rel)[0] + return no_ext.replace(os.path.sep, ".") + + +def find_server_modules(): + """Finds all importable Python modules across server directories.""" + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + modules = [] + + for cfg in SERVER_CONFIGS: + full_pkg_root = os.path.join(repo_root, cfg["pkg_root"]) + if not os.path.exists(full_pkg_root): + continue + + if full_pkg_root not in sys.path: + sys.path.insert(0, full_pkg_root) + + full_search_dir = os.path.join(repo_root, cfg["dir"]) + for root, dirs, files in os.walk(full_search_dir): + dirs[:] = [ + d + for d in dirs + if d not in SKIP_DIRS and not d.endswith(".egg-info") + ] + + for f in sorted(files): + if ( + not f.endswith(".py") + or f in SKIP_FILES + or f.startswith("test_") + or f.endswith("_test.py") + ): + continue + + full_path = os.path.join(root, f) + mod_name = file_to_module_name(full_path, full_pkg_root) + modules.append((mod_name, full_path)) + + return modules + + +def run_import_checks(): + """Attempts to import all discovered modules and returns a list of failed files.""" + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + for cfg in SERVER_CONFIGS: + full_pkg_root = os.path.join(repo_root, cfg["pkg_root"]) + if os.path.exists(full_pkg_root) and full_pkg_root not in sys.path: + sys.path.insert(0, full_pkg_root) + + modules = find_server_modules() + failed = [] + + print(f"Testing {len(modules)} Python modules across server packages...") + for mod_name, full_path in modules: + try: + spec = importlib.util.spec_from_file_location(mod_name, full_path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load spec for {full_path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + except Exception as e: # noqa: BLE001 + print(f"✗ {full_path} ({mod_name}): {e}") + failed.append((full_path, mod_name, str(e))) + + return failed + + +def main(): + failed = run_import_checks() + if failed: + print(f"\n{len(failed)} module(s) failed import tests:") + for path, mod, err in failed: + print(f" - {mod} ({path}): {err}") + return 1 + else: + print("✓ All Python modules import successfully") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 00000000..3328d9b0 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,15 @@ +"""Unit test to validate that all server package modules are importable.""" + +import os +import sys + +repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if repo_root not in sys.path: + sys.path.insert(0, repo_root) + +from tests.check_imports import run_import_checks + + +def test_all_server_modules_importable(): + failed = run_import_checks() + assert not failed, f"{len(failed)} module(s) failed import: {failed}"