diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ba9f4f6d..0aca6420 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -337,20 +337,45 @@ class _NeverBack(_dt.datetime): return t +_last_signature_second = [0] + + +def _signature_second_never_back(self) -> int: + """The session cookie's signing clock (itsdangerous, whole seconds), never going back: it refuses + a signature from the future, so a step back across a second boundary refused the cookie a + sign-in had just set. Read through the module at call time, so a probe can step it.""" + import itsdangerous.timed + + t = max(int(itsdangerous.timed.time.time()), _last_signature_second[0]) + _last_signature_second[0] = t + return t + + @pytest.fixture(autouse=True) def _clock_never_goes_back(monkeypatch): """Rows a test makes one after another are stamped in that order. The wall clock of the docker VM the suite runs in steps BACK now and then (measured: 2 steps in 20 s, up to 0.5 s), and a test that makes three files and reads them newest first then got them out of order. Every loaded `app.*` module that took `datetime` from the datetime module reads this clock instead (a sweep, - as the limiter reset below does, so a new module is covered the day it lands). A database + as the limiter reset below does, so a new module is covered the day it lands) — and so does every + test module, so a test comparing a stamp the app wrote with "now" reads the same clock. The + session cookie's signature has a clock of its own (`_signature_second_never_back`). A database default (`server_default=func.now()`) is Postgres's clock and is not covered: a test that orders such rows made within a second of each other gives them distinct times itself.""" import sys + from itsdangerous import TimestampSigner + + tests_dir = os.path.dirname(os.path.abspath(__file__)) for name, mod in list(sys.modules.items()): - if (name == "app" or name.startswith("app.")) and vars(mod).get("datetime") is _dt.datetime: + if vars(mod).get("datetime") is not _dt.datetime: + continue + mod_file = getattr(mod, "__file__", None) or "" + if name == "app" or name.startswith("app.") or mod_file.startswith(tests_dir + os.sep): monkeypatch.setattr(mod, "datetime", _NeverBack) + # Per test: a cookie never outlives its test, and a probe's stepped clock must not carry over. + _last_signature_second[0] = 0 + monkeypatch.setattr(TimestampSigner, "get_timestamp", _signature_second_never_back) @pytest.fixture(autouse=True) diff --git a/backend/tests/test_test_clock.py b/backend/tests/test_test_clock.py new file mode 100644 index 00000000..71a599fa --- /dev/null +++ b/backend/tests/test_test_clock.py @@ -0,0 +1,51 @@ +"""The suite's clock (conftest `_clock_never_goes_back`), DB-lane. + +The wall clock of the docker VM the suite runs in steps BACK now and then (measured: up to 0.5 s). +These hold the two places that read it besides the app: the session cookie's signature, and a test +comparing a stamp the app wrote with "now". +""" + +import time +from datetime import datetime, timezone +from types import SimpleNamespace + +import itsdangerous.timed + +_PW = "correct horse battery" + + +def test_a_cookie_signed_just_before_the_clock_steps_back_still_signs_in( + client, make_user, monkeypatch +): + # The signature carries whole seconds and itsdangerous refuses one from the future: a step back + # across a second boundary refused the cookie a sign-in had just set (a 401 at random). + clock = [int(time.time()) + 0.2] + monkeypatch.setattr(itsdangerous.timed, "time", SimpleNamespace(time=lambda: clock[0])) + make_user(email="clock@example.com", password=_PW) + r = client.post("/auth/password-login", json={"email": "clock@example.com", "password": _PW}) + assert r.status_code == 200 + clock[0] -= 0.5 + assert client.get("/api/me/notifications").status_code == 200 + + +def test_a_test_reads_the_clock_the_app_stamps_with(monkeypatch): + # A test asserting "the app's stamp is not in the future" compared it with the wall clock, which + # may have stepped back since the app read its own. + import conftest + from app import diagnostics + + # The app reads 12:00:01, then the wall clock steps back half a second (a time past any real one, + # so a test reading the wall clock instead is behind the stamp). + later = datetime(2099, 1, 1, 12, 0, 1, tzinfo=timezone.utc) + stepped_back = datetime(2099, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc) + readings = iter([later]) + + class Stepping(datetime): + @classmethod + def now(cls, tz=None): + return next(readings, stepped_back) + + monkeypatch.setattr(conftest._NeverBack, "clock", Stepping) + monkeypatch.setattr(conftest._NeverBack, "last", None) + stamped = diagnostics.datetime.now(timezone.utc) + assert datetime.now(timezone.utc) >= stamped