Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ jobs:
run: uv sync --all-extras

- name: Run pytest
run: uv run pytest --cov=mitreattack
run: uv run --extra dev pytest -n 2 --cov=mitreattack
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
- Before committing, run `just lint`.
- `just lint`: run pre-commit hooks across the repo.
- `just test`: run the pytest suite.
- `just test-xdist`: run the pytest suite in parallel.
- `just test-cov`: run tests with coverage for `mitreattack`.
- `just test-cov-xdist`: run tests with coverage in parallel.
- `just build`: build distributions with `uv build`.
- Without `just`, run the same tools through `uv run ...`.

Expand All @@ -32,6 +34,10 @@
- Framework: `pytest` (with `pytest-cov` for coverage checks).
- Place tests under `tests/` and name files/functions `test_*.py` / `test_*`.
- Add or update tests for behavior changes, especially around STIX parsing and changelog/diff output paths.
- Tests that need real ATT&CK STIX data should use the shared STIX fixtures instead of downloading or
preparing bundles directly.
- Parallel runs warm the shared STIX cache before workers start; update `DEFAULT_ATTACK_STIX_PREP` in
`tests/conftest.py` if a new xdist-backed test needs another ATT&CK release.
- Run `just test` locally before opening a PR; use `just test-cov` for larger changes.

## Commit & Pull Request Guidelines
Expand Down
7 changes: 7 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,17 @@ Run `just` with no arguments to see all available commands. Here are the most co
```bash
just lint # Run pre-commit hooks (ruff format) on all files
just test # Run tests
just test-xdist # Run tests in parallel
just test-cov # Run tests with coverage report
just test-cov-xdist # Run tests with coverage in parallel
just build # Build the package
```

Tests that need real ATT&CK STIX data should use the shared STIX fixtures instead of downloading or
preparing bundles directly. Parallel test runs warm the shared STIX cache before workers start; if a
new xdist-backed test needs an additional ATT&CK release, update the cache warmup list in
`tests/conftest.py`.

### Pull Requests

When making a pull request, please make sure to:
Expand Down
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ ruff-format:
test:
uv run pytest

# Run tests in parallel
test-xdist workers="auto":
uv run --extra dev pytest -n {{ workers }}

# Run tests with coverage in parallel
test-cov-xdist workers="auto":
uv run --extra dev pytest -n {{ workers }} --cov=mitreattack

# Run tests with coverage
test-cov:
uv run pytest --cov=mitreattack
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dev = [
"pytest>=8.4.2",
"pytest-cov>=7.0.0",
"pytest-dotenv>=0.5.2",
"pytest-xdist>=3.8.0",
"python-semantic-release>=10.5.0",
"responses>=0.25.8",
"ruff>=0.14.2",
Expand Down Expand Up @@ -128,3 +129,7 @@ version_files = [
"docs/conf.py:^release = ['\"](.*)['\"]",
"mitreattack/__init__.py:^__version__ = ['\"](.*)['\"]",
]

[[tool.uv.index]]
url = "https://pypi.org/simple"
default = true
4 changes: 2 additions & 2 deletions tests/changelog/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1594,10 +1594,10 @@ def golden_161_170_changelog_dir():


@pytest.fixture(scope="session")
def generated_161_170_diffstix(tmp_path_factory) -> DiffStix:
def generated_161_170_diffstix() -> DiffStix:
"""Create and cache a DiffStix instance for reuse across tests."""
versions_param = ["16.1", "17.0"]
result_paths = _download_attack_stix_data(versions_param, tmp_path_factory)
result_paths = _download_attack_stix_data(versions_param)
return DiffStix(
domains=["enterprise-attack", "mobile-attack", "ics-attack"],
old=result_paths["16.1"],
Expand Down
95 changes: 78 additions & 17 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Common fixtures and utilities for testing mitreattack-python."""

import os
from pathlib import Path

import pytest
from loguru import logger
Expand All @@ -16,6 +17,18 @@
STIX_LOCATION_ENTERPRISE = os.getenv("STIX_LOCATION_ENTERPRISE")
STIX_LOCATION_MOBILE = os.getenv("STIX_LOCATION_MOBILE")
STIX_LOCATION_ICS = os.getenv("STIX_LOCATION_ICS")
TEST_CACHE_DIR = Path(
os.getenv("MITREATTACK_TEST_CACHE_DIR", Path(__file__).resolve().parent.parent / ".pytest_cache" / "attack-stix")
)
DEFAULT_ATTACK_STIX_PREP = (None, ["16.1", "17.0"])


def pytest_sessionstart(session):
"""Warm the ATT&CK STIX cache before xdist test sessions start."""
config = session.config
if _should_prepare_attack_stix_cache(config):
for versions_param in DEFAULT_ATTACK_STIX_PREP:
_download_attack_stix_data(versions_param, config=config)


def _parse_version_param(versions_param):
Expand Down Expand Up @@ -81,7 +94,42 @@ def _get_stix_file_path(attack_stix_dir, domain, version_key="latest"):
return f"{attack_stix_dir[first_version]}/{domain}-attack.json"


def _download_attack_stix_data(versions_param, tmp_path_factory):
def _get_release_dir(download_dir: Path, release: str) -> Path:
"""Return the cache directory for a specific ATT&CK release."""
return download_dir / f"v{release}"


def _is_xdist_worker(config=None) -> bool:
"""Return whether the current process is an xdist worker."""
if config is not None and getattr(config, "workerinput", None) is not None:
return True
return bool(os.getenv("PYTEST_XDIST_WORKER"))


def _should_prepare_attack_stix_cache(config=None) -> bool:
"""Return whether the current pytest process should warm the shared STIX cache."""
if _is_xdist_worker(config):
return False

if config is None:
return False

numprocesses = getattr(getattr(config, "option", None), "numprocesses", None)
return bool(numprocesses and int(numprocesses) > 0)


def _stix_bundles_present(download_dir: Path, releases: list[str]) -> bool:
"""Check whether all cached STIX bundles needed for a run already exist."""
domains = ["enterprise", "mobile", "ics"]
for release in releases:
release_dir = _get_release_dir(download_dir, release)
for domain in domains:
if not (release_dir / f"{domain}-attack.json").exists():
return False
return True


def _download_attack_stix_data(versions_param, config=None):
"""Download ATT&CK STIX data and return paths.

This is the core download logic shared by multiple fixtures.
Expand All @@ -90,41 +138,54 @@ def _download_attack_stix_data(versions_param, tmp_path_factory):
----------
versions_param : None, str, list, or dict
Version parameter to parse
tmp_path_factory : pytest.TempPathFactory
Pytest temp path factory
config : pytest.Config, optional
Active pytest config used to determine read-only cache behavior.

Returns
-------
dict
Dictionary mapping version to download directory path
"""
versions, stix_version = _parse_version_param(versions_param)
requested_versions = versions or [LATEST_VERSION]

logger.debug(f"Downloading the ATT&CK STIX {stix_version} data for versions: {versions}")
download_dir = tmp_path_factory.mktemp("attack-releases") / f"stix-{stix_version}"
logger.debug(f"Preparing ATT&CK STIX {stix_version} data for versions: {requested_versions}")
download_dir = TEST_CACHE_DIR / f"stix-{stix_version}"
download_dir.mkdir(parents=True, exist_ok=True)

download_domains(
domains=["enterprise", "mobile", "ics"],
download_dir=download_dir,
all_versions=False,
stix_version=stix_version,
attack_versions=versions,
)
if _stix_bundles_present(download_dir, requested_versions):
logger.debug(f"Reusing cached ATT&CK STIX bundles from {download_dir}")
else:
if _is_xdist_worker(config):
requested = ", ".join(requested_versions)
raise RuntimeError(
"ATT&CK STIX cache is missing required bundles for "
f"{requested}. xdist runs should warm this cache before workers start. "
"If you added a new ATT&CK version to xdist-backed tests, update DEFAULT_ATTACK_STIX_PREP."
)
logger.debug(f"Downloading ATT&CK STIX bundles into cache at {download_dir}")
download_domains(
domains=["enterprise", "mobile", "ics"],
download_dir=download_dir,
all_versions=False,
stix_version=stix_version,
attack_versions=versions,
)

# Build return dictionary
result_paths = {}
if versions is None:
result_paths["latest"] = download_dir / f"v{LATEST_VERSION}"
result_paths["latest"] = _get_release_dir(download_dir, LATEST_VERSION)
else:
# Return paths for each requested version
for version in versions:
result_paths[version] = download_dir / f"v{version}"
result_paths[version] = _get_release_dir(download_dir, version)

return result_paths


@pytest.fixture(autouse=True, scope="session")
def attack_stix_dir(request, tmp_path_factory):
@pytest.fixture(scope="session")
def attack_stix_dir(request):
"""Download ATT&CK STIX data and return paths.

Can be parametrized to download specific versions:
Expand All @@ -151,7 +212,7 @@ def attack_stix_dir(request, tmp_path_factory):
Directory paths for requested ATT&CK versions
"""
versions_param = getattr(request, "param", None)
result_paths = _download_attack_stix_data(versions_param, tmp_path_factory)
result_paths = _download_attack_stix_data(versions_param, config=request.config)
yield result_paths


Expand Down
24 changes: 24 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.