diff --git a/tests/e2e/lifecycle.py b/tests/e2e/lifecycle.py index ecbfee2c579..fdf2137584e 100644 --- a/tests/e2e/lifecycle.py +++ b/tests/e2e/lifecycle.py @@ -22,7 +22,8 @@ class E2ECase(Protocol): """A stateful e2e check run against a long-lived proxy. init() acquires resources, run() exercises behaviour and asserts, teardown() - releases everything init() created. teardown() must run even if run() raises. + releases everything init() created. teardown() must run even if init() fails + partway or run() raises. """ def init(self) -> None: ... @@ -35,11 +36,14 @@ class E2ECase(Protocol): def run_case(case: E2ECase) -> None: """Drive a case through its lifecycle: init -> run -> teardown. - teardown always runs, even if run() raises (or skips), so resources the case - created on the long-lived proxy are released. + teardown always runs - even when init() fails partway or run() raises (or + skips) - so resources the case already registered on the long-lived proxy are + released. init() is inside the try because cases register cleanups + progressively (e.g. create team, then user, then key), and a failure after + the first creation must still release what came before. """ - case.init() try: + case.init() case.run() finally: case.teardown() diff --git a/tests/e2e/test_lifecycle.py b/tests/e2e/test_lifecycle.py new file mode 100644 index 00000000000..d3c559dd2ed --- /dev/null +++ b/tests/e2e/test_lifecycle.py @@ -0,0 +1,46 @@ +"""Unit coverage for the lifecycle harness (lifecycle.run_case). + +Cases register cleanups progressively during init() (create team, then user, then +key), so a failure partway through init() must still release whatever was already +created on the long-lived shared proxy. This guards that contract. +""" + +from dataclasses import dataclass, field +from typing import Callable, List + +import pytest + +from lifecycle import run_case + + +@dataclass +class _PartialInitCase: + """init() registers a cleanup, then raises before finishing - mirroring a real + case that creates a resource, registers its delete, then fails on the next + step.""" + + released: List[str] = field(default_factory=list) + _undo: List[Callable[[], None]] = field(default_factory=list) + + def init(self) -> None: + self._undo.append(lambda: self.released.append("first")) + raise RuntimeError("init failed after registering the first resource") + + def run(self) -> None: + raise AssertionError("run() must not execute when init() failed") + + def teardown(self) -> None: + for undo in reversed(self._undo): + undo() + + +def test_run_case_releases_resources_when_init_fails_partway() -> None: + case = _PartialInitCase() + + with pytest.raises(RuntimeError, match="init failed"): + run_case(case) + + assert case.released == ["first"], ( + "a resource registered before init() failed must still be released, or it " + "leaks on the long-lived shared proxy" + )