test: the cookie signer and test modules read the never-back clock too
The docker VM's clock steps back: itsdangerous refused a cookie signed in the next second (a random 401 right after a sign-in), and a test compared an app stamp with the raw wall clock.
This commit is contained in:
@@ -337,20 +337,45 @@ class _NeverBack(_dt.datetime):
|
|||||||
return t
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _clock_never_goes_back(monkeypatch):
|
def _clock_never_goes_back(monkeypatch):
|
||||||
"""Rows a test makes one after another are stamped in that order. The wall clock of the docker
|
"""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
|
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
|
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,
|
`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
|
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."""
|
such rows made within a second of each other gives them distinct times itself."""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from itsdangerous import TimestampSigner
|
||||||
|
|
||||||
|
tests_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
for name, mod in list(sys.modules.items()):
|
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)
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user