summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_session_renewal.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_session_renewal.py')
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py227
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")