diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_session_renewal.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_session_renewal.py | 227 |
1 files changed, 227 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py new file mode 100644 index 0000000..38db67f --- /dev/null +++ b/packages/meshbay-hub/tests/test_session_renewal.py @@ -0,0 +1,227 @@ +""" +Staying signed in. + +Reported: after an hour or so of watching a film, every action answers "token +expired or invalid", and the only way back is signing out and in again. Also on +simply reopening the tab the next day. + +Both come from the same place. The access token lasts an hour and the refresh +token thirty days, but nothing used the second one. `hubFetch` reported a 401 as +an error like any other, and watching a film is precisely the activity during +which the hub hears nothing at all — the video travels over WebRTC — so the hour +ran out with no request to notice. + +Underneath that was a worse one. The hub *rotates*: `/v1/users/token/refresh` +revokes the token presented, returns a replacement, and treats a revoked token +presented again as theft, revoking the whole family. The client kept only the +access token out of that response and dropped the new refresh token. So the +refresh token was spent on first use, and the second attempt did not merely fail +— it destroyed the family, which is why signing out and back in was the only +cure. + +These run the shipped `hubFetch`, `refreshAccessToken` and `ensureFreshToken` +against a fake hub that enforces the rotation rule. That rule is the point: a +stub which accepted the same refresh token twice would have passed against the +broken client. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "app.js" +HARNESS = Path(__file__).parent / "harness" / "session_harness.mjs" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not APP.exists(), + reason="node or the SPA sources are not available") + + +def _run(scenario: str, app: Path = APP) -> dict: + proc = subprocess.run( + ["node", str(HARNESS), str(app), json.dumps({"scenario": scenario})], + capture_output=True, text=True, timeout=60) + assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}" + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +@pytest.fixture(scope="module") +def broken(tmp_path_factory): + """The client as it shipped: the rotated refresh token dropped.""" + out = tmp_path_factory.mktemp("session") / "broken.js" + src = APP.read_text() + replaced = src.replace( + " refreshToken: data.refresh_token || _auth.refreshToken,", + " refreshToken: _auth.refreshToken,") + assert replaced != src, ( + "could not reconstruct the defect — the line it hinged on has moved, " + "and the A/B below would be comparing the fix against itself") + out.write_text(replaced) + return out + + +# ── The reported symptom ────────────────────────────────────────────────────── + +def test_an_expired_access_token_renews_itself(app=None): + """A tab reopened the next morning, or a film watched for an hour.""" + r = _run("expired-access") + assert not r["signedOut"], "the session was thrown away instead of renewed" + assert r["finalCallAccepted"], "the request was not replayed after renewing" + assert r["refreshCalls"] == 1 + + +def test_renewal_survives_being_needed_more_than_once(): + """The defect underneath the symptom. + + The hub hands back a new refresh token every time and revokes the old one. + Keep the old one and the second renewal is read as theft. + """ + r = _run("repeat") + assert not r["familyRevoked"], ( + f"the family was revoked after presenting {r['refreshTokensPresented']} " + "— the rotated token is not being stored") + assert not r["signedOut"] + assert len(set(r["refreshTokensPresented"])) == len(r["refreshTokensPresented"]), ( + f"the same refresh token was presented twice: {r['refreshTokensPresented']}") + + +def test_the_defect_is_reproduced_by_dropping_the_rotated_token(broken): + """Otherwise the test above proves nothing. + + This is the shipped behaviour, and it ends exactly where the report did: + signed out, with nothing but signing back in to be done about it. + """ + r = _run("repeat", app=broken) + assert r["familyRevoked"], ( + "dropping the rotated refresh token no longer breaks anything, so the " + "test above is not guarding what it claims") + assert r["signedOut"] + assert r["refreshTokensPresented"] == ["RT-1", "RT-1"] + + +# ── Renewing exactly once ───────────────────────────────────────────────────── + +def test_two_requests_racing_share_one_renewal(): + """Two 401s at the same instant must not present the token twice. + + They would each renew, the second presenting what the first had already + spent — which the hub cannot tell from a stolen token, and answers by + revoking the family. The failure mode is worse than the problem. + """ + r = _run("concurrent") + assert r["refreshCalls"] == 1, ( + f"{r['refreshCalls']} renewals for one expiry") + assert not r["familyRevoked"] + assert r["finalCallAccepted"], "the queued requests were not replayed" + + +def test_a_valid_token_is_left_alone(): + """Renewing on every call would be its own kind of broken.""" + r = _run("fresh-access") + assert r["refreshCalls"] == 0, "a token with an hour left was renewed anyway" + assert r["finalCallAccepted"] + + +# ── When there is genuinely nothing left ────────────────────────────────────── + +def test_an_unusable_refresh_token_signs_out_cleanly(): + """Thirty days later, or after the family was revoked. + + There is nothing to salvage, and the alternative is a session that fails + every call for ever while looking signed in. + """ + r = _run("refresh-rejected") + assert r["signedOut"], ( + "the session survived a refusal, so every later call fails with no way " + "for the person to understand why") + assert r["storedRefreshToken"] is None + assert r["refreshCalls"] == 1, "it kept trying a token the hub had refused" + + +# ── The lifetimes themselves ────────────────────────────────────────────────── + +def test_the_access_token_outlives_a_film_on_its_own(): + """Not because it has to — renewal covers any length — but as a floor. + + Renewal makes the access token's life invisible in normal use. This exists + for the abnormal one: if renewal fails, a token this short is how long + someone has before they notice. An hour was less than a feature film. + """ + from meshbay_hub.config import JWTConfig + ttl = JWTConfig().access_token_ttl + assert ttl >= 4 * 3600, ( + f"{ttl / 3600:.1f} h does not cover a long film if renewal fails") + assert ttl <= 12 * 3600, ( + f"{ttl / 3600:.1f} h is a long time for a leaked token to stay usable, " + "and it lets the renewal path go a whole day without being exercised — " + "which is how it came to be broken without anyone noticing") + + +def test_the_session_is_much_longer_than_the_token(): + """The two must not be confused: the session is the refresh token.""" + from meshbay_hub.config import JWTConfig + cfg = JWTConfig() + assert cfg.refresh_token_ttl >= 7 * 86400 + assert cfg.refresh_token_ttl > cfg.access_token_ttl * 20, ( + "the refresh token is barely longer than the access token, so renewing " + "buys almost nothing and signing in again comes round just as fast") + + +# ── The margin ──────────────────────────────────────────────────────────────── + +def test_renewal_happens_before_expiry_not_after(): + """A margin, so the first click after a long film does not pay for a 401.""" + src = APP.read_text() + import re + margin = int(re.search(r"const TOKEN_RENEW_MARGIN_S = (\d+)", src).group(1)) + assert margin >= 300, ( + f"{margin} s of margin against a one-hour token is thin: a backgrounded " + "tab has its timers throttled and may not check for minutes") + assert "visibilitychange" in src, ( + "nothing re-checks when the tab comes back, which is exactly when the " + "token is most likely to have aged out unnoticed") + + +# ── What renewal must not disturb ───────────────────────────────────────────── + +def test_renewing_does_not_tear_down_the_webrtc_connection(): + """The regression that renewal introduced. + + The effect that dials the node listed `token` among its dependencies. That + was harmless while a token never changed during a session — it only ran + out. Once the session renews itself the string rotates, and the effect tore + the connection down and rebuilt it each time. Worst on arrival: a stored + token past its life is renewed the instant the page mounts, which is when + the group page is negotiating ICE, so the browser abandoned the handshake + and the node sat in `connecting` for ever. + + Signing in or out must still re-run it, so the dependency is whether there + is a token, not which one. + """ + src = APP.read_text() + i = src.index("means tearing down the WebRTC connection") + deps = src[i:src.index(");", i)] + assert "Boolean(token)" in deps, ( + "the WebRTC effect depends on the token's value again — every renewal " + "drops the connection, and one landing mid-handshake never recovers") + assert "[groupId, token," not in deps + + +def test_the_connection_signs_its_offer_with_a_live_token(): + """The other half: not re-running means the captured token can be stale. + + It signs the offer relayed through the hub, where an expired one is a 401 + and no connection at all. + """ + src = APP.read_text() + connect = src[src.index("const connect = async () => {"):] + connect = connect[:connect.index("\n };")] + assert "await ensureFreshToken()" in connect, ( + "the offer is signed with whatever token the effect captured, which is " + "no longer refreshed by a re-run") + assert "new window.MeshBayTransport('', live)" in connect, ( + "the transport is built with the captured token rather than the live one") |