"""Make the repository root importable for the pipeline test suites, give every suite the application session the API requires since US07-02, and keep the evidence of any failure (US07-04). The suites drive the API the way the browser does — module-level ``httpx`` calls and ``TestClient`` — so instead of threading a cookie through several hundred call sites, both clients bootstrap the session themselves exactly like ``frontend/js/api.js``: fetch ``/api/v1/session`` once, then send the cookie plus the CSRF header, and re-bootstrap once on 401 (a restarted server issues a new session). Security tests deliberately bypass this by constructing their own ``httpx.Client``; only the module-level helpers are wrapped. """ import sys from pathlib import Path REPO = Path(__file__).resolve().parents[1] if str(REPO) not in sys.path: sys.path.insert(0, str(REPO)) import httpx import httpx._api # httpx.get/post resolve `request` in this module import pytest from starlette.testclient import TestClient from photo_pipeline.api.security import CSRF_HEADER, SESSION_COOKIE _SESSIONS: dict[str, tuple[str, str]] = {} # base url -> (session id, csrf token) def _base(url) -> str: parts = httpx.URL(str(url)) return f"{parts.scheme}://{parts.netloc.decode()}" def _bootstrap(base: str) -> tuple[str, str]: if base not in _SESSIONS: response = httpx.Client(timeout=10).get(f"{base}/api/v1/session") _SESSIONS[base] = ( response.cookies.get(SESSION_COOKIE, ""), response.json().get("csrf_token", ""), ) return _SESSIONS[base] def session_client(base_url: str, **kwargs) -> httpx.Client: """An ``httpx.Client`` that has bootstrapped its own application session. For suites that keep one client for a whole journey; it holds its own cookie, so a client made after a server restart picks up the new session by construction. """ client = httpx.Client(base_url=base_url, **kwargs) client.headers[CSRF_HEADER] = client.get("/api/v1/session").json()["csrf_token"] return client def _authorized(url, headers, cookies): session_id, token = _bootstrap(_base(url)) headers = dict(headers or {}) headers.setdefault(CSRF_HEADER, token) cookies = dict(cookies or {}) cookies.setdefault(SESSION_COOKIE, session_id) return headers, cookies @pytest.fixture(autouse=True, scope="session") def _api_session(): real_request, real_stream = httpx._api.request, httpx._api.stream real_client_request, real_client_init = TestClient.request, TestClient.__init__ def request(method, url, *, headers=None, cookies=None, **kwargs): sent, jar = _authorized(url, headers, cookies) response = real_request(method, url, headers=sent, cookies=jar, **kwargs) if response.status_code == 401: _SESSIONS.pop(_base(url), None) sent, jar = _authorized(url, headers, cookies) response = real_request(method, url, headers=sent, cookies=jar, **kwargs) return response def stream(method, url, *, headers=None, cookies=None, **kwargs): sent, jar = _authorized(url, headers, cookies) return real_stream(method, url, headers=sent, cookies=jar, **kwargs) def client_init(self, app, *args, base_url="http://127.0.0.1", **kwargs): # The default "http://testserver" is not a local host, which is exactly what # the Host check refuses; in-process tests are still same-origin callers. real_client_init(self, app, *args, base_url=base_url, **kwargs) def client_request(self, method, url, *, headers=None, **kwargs): if CSRF_HEADER not in self.headers: response = real_client_request(self, "GET", "/api/v1/session") self.headers[CSRF_HEADER] = response.json()["csrf_token"] return real_client_request(self, method, url, headers=headers, **kwargs) httpx._api.request, httpx.request = request, request httpx._api.stream, httpx.stream = stream, stream TestClient.request, TestClient.__init__ = client_request, client_init yield httpx._api.request, httpx.request = real_request, real_request httpx._api.stream, httpx.stream = real_stream, real_stream TestClient.request, TestClient.__init__ = real_client_request, real_client_init @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): """On failure, keep the temporary library, database, journals, logs, and seed. ``tmp_path`` is deleted a few runs later and its contents are the only record of what a randomized or fault-injected failure actually did, so they are copied out while they still exist (US07-04). """ outcome = yield report = outcome.get_result() if report.when != "call" or not report.failed: return root = item.funcargs.get("tmp_path") if root is None or not Path(root).exists(): return from tests._artifacts import collect try: destination = collect( Path(root), item.nodeid, properties=dict(getattr(report, "user_properties", [])) ) except OSError as error: # never let evidence collection mask the real failure report.sections.append(("failure artifacts", f"could not be collected: {error}")) else: report.sections.append(("failure artifacts", str(destination)))