"""Borrowing the user's existing sign-in instead of interrupting them for a password. This module reads the user's real browser, so the tests are mostly about what it must REFUSE to do. Nothing here touches a real store or a keychain: the reader is stubbed at every call site. """ import os import re import pytest from backend.apps.agents.browser import browser_session_import as si from backend.apps.settings.models import AppSettings P_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) P_MAIN_JS = os.path.join(P_REPO_ROOT, "electron", "main.js") RECORDS = [{"name": "sid", "value": "opaque", "domain": ".x.com", "path": "/", "secure": True, "httponly": True, "expires": 1900000000.0}] def test_opt_in_is_off_by_default(): """Reading someone's real browser is their call to make explicitly. If this ever defaults True, an upgrade would silently start reading stores the user never agreed to expose.""" assert si.is_enabled(AppSettings()) is False def test_opt_in_flips(): s = AppSettings() s.browser_import_signins = True assert si.is_enabled(s) is True def test_google_properties_route_to_the_sso_scope(): """A Gmail/YouTube session lives on the parent SSO domain, not the property's own host, and the reader has a NAMED scope for it. Getting this wrong means either no session at all or a general sweep of every google entry the user owns.""" for d in ("mail.google.com", "google.com", "docs.google.com", "youtube.com", "www.youtube.com"): assert si.is_google_property(d), d for d in ("reddit.com", "x.com", "notgoogle.com", "google.com.evil.net", ""): assert not si.is_google_property(d), d def test_domain_normalisation_matches_the_handoff(): """One definition of 'which site is this', shared with the login handoff, or the two can disagree about which domain we just borrowed for.""" assert si.site_domain("https://www.reddit.com/submit?x=1") == "reddit.com" assert si.site_domain("x.com") == "x.com" assert si.site_domain("") == "" @pytest.mark.asyncio async def test_no_session_never_wakes_the_bridge(monkeypatch): """Nothing to import means nothing to send. Calling the renderer with an empty payload would burn a round trip and log a bogus failure.""" called = [] monkeypatch.setattr(si, "read_site_records", lambda d: []) monkeypatch.setattr(si.ws_manager, "send_browser_command", lambda *a, **k: called.append(a) or {}) result = await si.import_signin("x.com", "b1") assert result.outcome == "no_session" assert result.ok is False assert called == [] @pytest.mark.asyncio async def test_empty_domain_reads_nothing(monkeypatch): """A blank URL must not turn into a wildcard read.""" monkeypatch.setattr(si, "read_site_records", lambda d: pytest.fail("must not read for an empty domain")) assert (await si.import_signin("", "b1")).outcome == "no_session" @pytest.mark.asyncio async def test_successful_import_reports_what_landed(monkeypatch): async def fake_send(rid, action, browser_id, params, **kw): assert action == "import_session" assert params["domain"] == "x.com" assert params["cookies"] == RECORDS return {"ok": True, "set": 1, "total": 1} monkeypatch.setattr(si, "read_site_records", lambda d: list(RECORDS)) monkeypatch.setattr(si.ws_manager, "send_browser_command", fake_send) result = await si.import_signin("https://x.com/compose/post", "b1") assert result.outcome == "imported" assert result.ok is True assert result.entries_applied == 1 assert result.domain == "x.com" @pytest.mark.asyncio async def test_bridge_error_is_a_result_not_an_exception(monkeypatch): """Every failure has to degrade into something the caller can fall back from, because the fallback (ask the user to sign in) is the behaviour that existed before this did.""" async def fake_send(*a, **k): return {"error": "No dashboard is connected."} monkeypatch.setattr(si, "read_site_records", lambda d: list(RECORDS)) monkeypatch.setattr(si.ws_manager, "send_browser_command", fake_send) result = await si.import_signin("x.com", "b1") assert result.outcome == "bridge_failed" assert result.ok is False @pytest.mark.asyncio async def test_applied_nothing_is_not_success(monkeypatch): """The bridge answering 'ok' while applying zero entries must NOT read as signed in, or the run skips the pause and then fails on a page it still cannot use.""" async def fake_send(*a, **k): return {"ok": True, "set": 0, "total": 4} monkeypatch.setattr(si, "read_site_records", lambda d: list(RECORDS)) monkeypatch.setattr(si.ws_manager, "send_browser_command", fake_send) assert (await si.import_signin("x.com", "b1")).ok is False def test_expiry_is_translated_out_of_chromium_time(monkeypatch): """Chromium counts microseconds from 1601; Electron wants unix seconds. Get this wrong and every borrowed entry is either already expired or session-scoped, so the sign-in dies on the next quit and the user quietly stops believing the feature works.""" monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", lambda d: [{"name": "sid", "value": "opaque", "expires_utc": 13400000000000000}, {"name": "tmp", "value": "opaque", "expires_utc": 0}]) out = si.read_site_records("x.com") assert out[0]["expires"] == pytest.approx(1755526400.0) assert out[1]["expires"] == 0.0, "a session entry must stay session-scoped, not become 1601" def test_the_whole_jar_travels_including_clearance_tokens(monkeypatch): """A real browser sends everything it has, so we do too. An earlier version held back the anti-bot clearance tokens (cf_clearance and friends) on the theory that ours could never match the user agent they were minted for. That was measured live on medium with the UA swap CONFIRMED firing in the Electron log, and it changed nothing in either direction, so the filter was carrying a story rather than its weight. Holding a cookie back is a claim about the site's auth that we could not support.""" monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", lambda d: [ {"name": "sid", "value": "opaque", "expires_utc": 0}, {"name": "uid", "value": "opaque", "expires_utc": 0}, {"name": "cf_clearance", "value": "opaque", "expires_utc": 0}, {"name": "__cf_bm", "value": "opaque", "expires_utc": 0}, ]) assert sorted(r["name"] for r in si.read_site_records("medium.com")) == [ "__cf_bm", "cf_clearance", "sid", "uid"] def test_google_reads_go_through_the_named_sso_scope(monkeypatch): """A Gmail borrow must use the reader's named SSO set, never a general sweep of the user's google entries.""" monkeypatch.setattr(si.browser_cookies, "read_google_session_records", lambda: [{"name": "SID", "value": "opaque", "expires_utc": 0}]) monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", lambda d: pytest.fail("google must not go through the generic read")) assert [r["name"] for r in si.read_site_records("mail.google.com")] == ["SID"] def test_unreadable_browser_degrades_instead_of_crashing(monkeypatch): """A locked keychain, a v20 app-bound store, a browser that isn't installed: all of it is a fallback, never an exception that kills the run.""" def boom(d): raise RuntimeError("read denied") monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", boom) assert si.read_site_records("x.com") == [] monkeypatch.setattr(si.browser_cookies, "p_best_store", boom) assert si.has_importable_session("x.com") is False @pytest.mark.asyncio async def test_a_broken_borrow_can_never_break_the_run(monkeypatch): """The class this seals: borrowing is a convenience bolted onto the critical path, so ANY failure inside it must cost at most the pause we were going to show anyway. Caught for real by the suite, where a loose settings double made the helper raise and killed the whole browser run before it could even reach the sign-in prompt.""" from backend.apps.agents.browser import browser_agent def boom(*a, **k): raise TypeError("settings double is not the real thing") monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", boom) assert await browser_agent.try_borrow_signin("acme.example", "b1", "", "") is False @pytest.mark.asyncio async def test_borrow_happens_at_the_door_not_only_at_the_wall(monkeypatch): """Borrowing only at a detected wall was too late: a task the model answers in one turn calls Done, which breaks the loop BEFORE the handoff runs, so short tasks never got the session at all. Navigating must carry it.""" from backend.apps.agents.browser import browser_agent seen = [] browser_agent.signin_borrowed.discard("x.com") monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", lambda s: True) monkeypatch.setattr(browser_agent.browser_session_import, "has_importable_session", lambda d: True) async def fake_import(domain, browser_id): seen.append(domain) return si.SessionImportResult(outcome="imported", domain=domain, entries_applied=3) monkeypatch.setattr(browser_agent.browser_session_import, "import_signin", fake_import) await browser_agent.borrow_signin_before_nav("https://x.com/compose/post", "b1") assert seen == ["x.com"], "navigating to a site must borrow its sign-in first" # Second navigate to the same site must not re-read the user's browser. await browser_agent.borrow_signin_before_nav("https://x.com/home", "b1") assert seen == ["x.com"], "a borrowed site must not be re-imported on every navigate" browser_agent.signin_borrowed.discard("x.com") @pytest.mark.asyncio async def test_pre_nav_borrow_respects_the_opt_in(monkeypatch): """The door is the busiest path in the whole agent, so the gate has to hold there too.""" from backend.apps.agents.browser import browser_agent browser_agent.signin_borrowed.discard("x.com") monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", lambda s: False) monkeypatch.setattr(browser_agent.browser_session_import, "has_importable_session", lambda d: pytest.fail("must not probe the user's browser while opted out")) await browser_agent.borrow_signin_before_nav("https://x.com/home", "b1") @pytest.mark.asyncio async def test_wall_handoff_asks_a_human_once_the_door_borrow_did_not_take(monkeypatch): """If we already borrowed at the door and are STILL at a wall, the session did not work. Re-importing identical values would change nothing, so this case belongs to the human, and silently returning True here would skip the prompt and strand the run.""" from backend.apps.agents.browser import browser_agent browser_agent.signin_borrowed.add("acme.example") monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", lambda s: True) monkeypatch.setattr(browser_agent.browser_session_import, "import_signin", lambda d, b: pytest.fail("must not re-import the same values")) try: assert await browser_agent.try_borrow_signin("acme.example", "b1", "", "") is False finally: browser_agent.signin_borrowed.discard("acme.example") def test_import_timeout_outlasts_the_hidden_window_warm(): """INVARIANT, and the second time this exact trap has bitten (see find_composer): the warm sits through the site's bot challenge inside the import command, so the command's timeout has to outlast the warm's own budget. Set them past each other and the window is killed mid-challenge, which throws away the entire reason the warm exists while still looking like a clean import.""" from backend.apps.agents.core.ws_manager import BROWSER_CMD_TIMEOUTS, BROWSER_CMD_TIMEOUT_DEFAULT with open(os.path.join(P_REPO_ROOT, "electron", "warmBorrowedSession.js"), encoding="utf-8") as fh: src = fh.read() budget_ms = sum(int(m) for m in re.findall( r"^const (?:LOAD_TIMEOUT_MS|SETTLE_MS|DESTROY_GRACE_MS) = (\d+);", src, re.M)) assert budget_ms > 0, "warm budget constants not found; did warmBorrowedSession change shape?" timeout_s = BROWSER_CMD_TIMEOUTS.get("import_session", BROWSER_CMD_TIMEOUT_DEFAULT) assert timeout_s * 1000 > budget_ms, ( f"import_session timeout {timeout_s}s must outlast the warm budget {budget_ms}ms") def test_agent_checks_the_opt_in_before_reading_anything(): """INVARIANT: the borrow helper must consult the setting FIRST. Pinned by source because the ordering is the whole consent story, and an innocent-looking reorder would start reading the user's browser before asking whether they wanted that.""" import inspect from backend.apps.agents.browser import browser_agent src = inspect.getsource(browser_agent.try_borrow_signin) gate = src.index("is_enabled") assert gate < src.index("has_importable_session"), "opt-in must be checked before probing" assert gate < src.index("import_signin"), "opt-in must be checked before importing" def test_partition_write_confines_entries_to_the_requested_domain(): """INVARIANT on the Electron side: importing one site must never plant another site's session in the partition. Pinned by source since main.js needs a live Electron to execute.""" with open(P_MAIN_JS, encoding="utf-8") as fh: src = fh.read() body = src[src.index("async function writePartitionCookies"):] body = body[:body.index("ipcMain.handle('set-partition-cookies'")] assert re.search(r"if \(host !== d && !host\.endsWith\(`\.\$\{d\}`\)\) continue;", body), \ "the per-entry domain confinement guard is gone from writePartitionCookies"