""" 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 import re from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" APP = STATIC / "hub-client.js" # The session/token machinery lives in hub-client.js (APP, above); the group # shell's own WebRTC-connect effect that consumes it is in group-page.js; the # periodic re-check that catches a backgrounded tab is App() in app.js. GROUP_PAGE = STATIC / "group-page.js" APP_JS = 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 APP_JS.read_text(), ( "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 = GROUP_PAGE.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 = GROUP_PAGE.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") # Asserted on the argument, not on the whole call: the first argument is # the hub's base URL and became configurable when the interface started # shipping in a package. Pinning the literal made this fail for a change # that had nothing to do with tokens. built = re.search(r"new window\.MeshBayTransport\(([^)]*)\)", connect) assert built, "the transport is not built in connect()" args = [a.strip() for a in built.group(1).split(",")] assert args[-1] == "live", ( f"the transport is built with {args[-1]!r} rather than the live token")