Skip to content

Commit 0aff216

Browse files
Merge issue 121: register copied CMP pytest markers safely
2 parents a31dbd9 + c16b216 commit 0aff216

9 files changed

Lines changed: 882 additions & 10 deletions

File tree

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ test-compatibility:
119119
test-factories:
120120
DTC_TEST_RUN_ID="$${DTC_TEST_RUN_ID:-make-$${PPID}}" \
121121
uv run --frozen pytest test_support/tests/test_factories.py \
122-
test_support/tests/test_runtime.py test_support/tests/test_safety.py -q
122+
test_support/tests/test_runtime.py test_support/tests/test_safety.py \
123+
test_support/tests/test_marker_registry.py -q
123124

124125
test-migrations:
125126
DTC_TEST_RUN_ID="$${DTC_TEST_RUN_ID:-make-$${PPID}}" \

_docs/compatibility/development-terminology-allowlist.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@
196196
},
197197
{
198198
"path": "core/tests/test_deployment_workflow.py",
199-
"sha256": "cd03a6869ed56b03b490a5f0106d4b2901de73b1dd25271450f39b52688d6dad",
199+
"sha256": "b17c2239eb4fc606af1ff8bd8de5c9f25132ca8065d35829892a919555716d2c",
200200
"class": "legacy_contract_test",
201201
"reason": "Workflow tests exercise removed inputs, frozen Gate-B records, and exact physical values.",
202202
"follow_up": "#94",

ci/tests/test_selection.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ def test_every_reviewed_application_closure_is_exact(root: str, labels: tuple[st
5959
(("deploy/cli.py",), "configuration_or_dependency"),
6060
(("scripts/a.py",), "configuration_or_dependency"),
6161
(("test_support/factories/context.py",), "configuration_or_dependency"),
62+
(("test_support/tests/test_marker_registry.py",), "configuration_or_dependency"),
6263
(("conftest.py",), "configuration_or_dependency"),
64+
(("e2e/pytest.ini",), "configuration_or_dependency"),
6365
(("sitecustomize.py",), "configuration_or_dependency"),
6466
(("pyproject.toml",), "configuration_or_dependency"),
6567
(("uv.lock",), "configuration_or_dependency"),

conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
158158
)
159159

160160

161+
@pytest.hookimpl(tryfirst=True)
161162
def pytest_runtest_setup(item: pytest.Item) -> None:
162163
safety = sorted({marker.name for marker in item.iter_markers()} & SAFETY_MARKERS)
163164
if safety:

core/tests/test_deployment_workflow.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ def test_gate_b_evidence_contract_is_atomic_offline_and_workflow_isolated(self)
961961
"core/tests/test_deployment_oidc_probe.py": (
962962
"58b447bc72ddfd11359f801087ba5a2f896f0d56bff7a832c6a2768d34f74b92"
963963
),
964-
"pyproject.toml": ("97b98e0a141e214993d46c2bb7fed9a1cf08e7aae1e64be45536d386c4fa3a24"),
964+
"pyproject.toml": ("af3177f75dc026c8b6fd55a1995fa891a971f5bb1732772c14a4adf3042b4c49"),
965965
"uv.lock": "070c1169e5902df2c7b202106da308a6b48060970b30804c08f13fed5ec66d9f",
966966
}
967967
for relative_path, expected_hash in unchanged_hashes.items():
@@ -1138,7 +1138,7 @@ def test_gate_b_operator_contract_is_exact_and_workflow_isolated(self) -> None:
11381138
"core/tests/test_deployment_oidc_probe.py": (
11391139
"58b447bc72ddfd11359f801087ba5a2f896f0d56bff7a832c6a2768d34f74b92"
11401140
),
1141-
"pyproject.toml": ("97b98e0a141e214993d46c2bb7fed9a1cf08e7aae1e64be45536d386c4fa3a24"),
1141+
"pyproject.toml": ("af3177f75dc026c8b6fd55a1995fa891a971f5bb1732772c14a4adf3042b4c49"),
11421142
"uv.lock": "070c1169e5902df2c7b202106da308a6b48060970b30804c08f13fed5ec66d9f",
11431143
}
11441144
for relative_path, expected_hash in frozen_hashes.items():

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ django_settings_module = "website.settings.test"
8989
[tool.pytest.ini_options]
9090
DJANGO_SETTINGS_MODULE = "website.settings.test"
9191
python_files = ["test_*.py"]
92-
addopts = "--strict-markers -p no:cacheprovider"
92+
pythonpath = ["."]
93+
addopts = "--strict-markers -p no:cacheprovider -p test_support.root_pytest_guard"
9394
markers = [
9495
"accessibility: deterministic WCAG A/AA route and state coverage",
9596
"core: bounded release-critical local Playwright coverage",
@@ -98,4 +99,12 @@ markers = [
9899
"remote_mutation: explicit isolated-development synthetic mutation",
99100
"live_email: explicit controlled-recipient live email smoke",
100101
"live_provider: explicit provider/webhook integration smoke",
102+
"smoke: core availability/auth smoke checks (run first, fail fast)",
103+
"provisioning: API course/content provisioning",
104+
"enrollment: enrollment and identity",
105+
"homework: homework submission + scoring + leaderboard flow",
106+
"project: project submission + peer review + scoring flow",
107+
"dashboards: dashboards and statistics pages",
108+
"teardown: cleanup and clean-state assertions",
109+
"email: Datamailer email verification via CMP's own send audit (dry-run render; xfails if Datamailer is not configured / dry-run is off on the deployment)",
101110
]

test_support/root_pytest_guard.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Structural boundary between unified pytest and the copied CMP E2E suite.
2+
3+
The root ``pyproject.toml`` loads this plugin explicitly. Additive command-line
4+
and ``PYTEST_ADDOPTS`` options, including ``--confcutdir`` and ``--rootdir``, remain protected.
5+
Replacing the configured ``addopts`` or explicitly disabling this plugin is an
6+
operator decision outside this boundary. An arbitrary in-process plugin can
7+
also intentionally unregister or corrupt other plugins, so that is not claimed
8+
as a supported threat boundary.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import posixpath
14+
from collections.abc import Generator, Sequence
15+
from dataclasses import dataclass
16+
from pathlib import Path, PurePosixPath
17+
18+
import pytest
19+
20+
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
21+
UNIFIED_PYTEST_CONFIG = REPOSITORY_ROOT / "pyproject.toml"
22+
UNIFIED_COPIED_E2E_DENIAL = (
23+
"copied CMP E2E tests cannot run under the unified root pytest configuration; "
24+
"use the standalone e2e/pytest.ini configuration"
25+
)
26+
27+
28+
@dataclass(frozen=True, slots=True)
29+
class _ItemProvenance:
30+
node_path: str | None
31+
source_paths: tuple[str, ...]
32+
33+
34+
_ORIGINAL_PROVENANCE = pytest.StashKey[_ItemProvenance]()
35+
36+
37+
@pytest.hookimpl(wrapper=True, tryfirst=True)
38+
def pytest_itemcollected(item: pytest.Item) -> Generator[None]:
39+
"""Snapshot provenance before ordinary item-collected hooks can mutate it."""
40+
item.stash[_ORIGINAL_PROVENANCE] = _capture_provenance(item)
41+
yield
42+
43+
44+
@pytest.hookimpl(trylast=True)
45+
def pytest_collection_finish(session: pytest.Session) -> None:
46+
_reject_selected_copied_e2e(session.config, session.items)
47+
48+
49+
@pytest.hookimpl(tryfirst=True)
50+
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None) -> None:
51+
del nextitem
52+
_reject_selected_copied_e2e(item.config, (item,))
53+
54+
55+
@pytest.hookimpl(tryfirst=True)
56+
def pytest_runtest_setup(item: pytest.Item) -> None:
57+
_reject_selected_copied_e2e(item.config, (item,))
58+
59+
60+
def _reject_selected_copied_e2e(
61+
config: pytest.Config,
62+
items: Sequence[pytest.Item],
63+
) -> None:
64+
if not _uses_unified_config(config):
65+
return
66+
if any(_is_copied_e2e_item(item) for item in items):
67+
raise pytest.UsageError(UNIFIED_COPIED_E2E_DENIAL)
68+
69+
70+
def _uses_unified_config(config: pytest.Config) -> bool:
71+
inipath = config.inipath
72+
return inipath is not None and Path(inipath).resolve() == UNIFIED_PYTEST_CONFIG
73+
74+
75+
def _is_copied_e2e_item(item: pytest.Item) -> bool:
76+
original = item.stash.get(_ORIGINAL_PROVENANCE, None)
77+
current = _capture_provenance(item)
78+
provenances = (current,) if original is None else (original, current)
79+
return any(_is_copied_e2e_provenance(provenance) for provenance in provenances)
80+
81+
82+
def _capture_provenance(item: pytest.Item) -> _ItemProvenance:
83+
paths: list[str] = []
84+
for collector in item.listchain():
85+
_append_resolved_path(paths, getattr(collector, "path", None))
86+
_append_resolved_path(paths, getattr(item, "path", None))
87+
module = getattr(item, "module", None)
88+
_append_resolved_path(paths, getattr(module, "__file__", None))
89+
return _ItemProvenance(
90+
node_path=_normalized_node_path(item.nodeid),
91+
source_paths=tuple(dict.fromkeys(paths)),
92+
)
93+
94+
95+
def _append_resolved_path(paths: list[str], value: object) -> None:
96+
if value is None:
97+
return
98+
try:
99+
paths.append(Path(str(value)).resolve().as_posix())
100+
except (OSError, RuntimeError, ValueError):
101+
return
102+
103+
104+
def _normalized_node_path(nodeid: str) -> str | None:
105+
raw_path = nodeid.partition("::")[0].replace("\\", "/")
106+
normalized = posixpath.normpath(raw_path)
107+
path = PurePosixPath(normalized)
108+
if normalized in {"", "."} or path.is_absolute() or ".." in path.parts:
109+
return None
110+
return path.as_posix()
111+
112+
113+
def _is_copied_e2e_provenance(provenance: _ItemProvenance) -> bool:
114+
if provenance.node_path == "e2e" or (provenance.node_path or "").startswith("e2e/"):
115+
return True
116+
for source in provenance.source_paths:
117+
try:
118+
relative = Path(source).relative_to(REPOSITORY_ROOT)
119+
except ValueError:
120+
continue
121+
if relative.parts[:1] == ("e2e",):
122+
return True
123+
return False

0 commit comments

Comments
 (0)