repaint
'), 0)") + assert _is_jpeg(await asyncio.wait_for(frames.get(), timeout=10)) # screencast followed the tab + finally: + caster.cancel() + await asyncio.gather(caster, return_exceptions=True) + await session.kill() + + asyncio.run(run()) + assert_no_chromium_left() + + +def test_fill_review_takeover_then_submit(ba, site, monkeypatch): + llm = ScriptedLLM() + monkeypatch.setattr(ba, "_make_browser_llm", lambda: llm) + frames: asyncio.Queue = asyncio.Queue(maxsize=8) + commands = [json.dumps({"type": "click", "x": 250, "y": 115}), "Grace Hopper", "submit"] + + events = asyncio.run(run_apply_flow(ba, f"{site}/form.html", commands, frame_queue=frames)) + + review = next(e for e in events if e.get("interactive")) + assert "review" in review["action"].lower() and review["screenshot"] + assert any(e["action"].startswith("Typed: Grace") for e in events) + assert llm.tasks == ["fill", "submit"] + assert events[-1]["done"] and events[-1]["submitted"] is True + assert not frames.empty() + assert_no_chromium_left() + + +def test_failed_submit_keeps_control_then_done(ba, site, monkeypatch): + monkeypatch.setattr(ba, "_make_browser_llm", lambda: ScriptedLLM(submit_success=False)) + events = asyncio.run(run_apply_flow(ba, f"{site}/form.html", ["submit", "done"])) + assert any("Couldn't confirm" in e["action"] for e in events) + assert events[-1]["submitted"] is True + + +def test_unfinished_fill_then_skip(ba, site, monkeypatch): + monkeypatch.setattr(ba, "_make_browser_llm", lambda: ScriptedLLM(fill_success=False)) + events = asyncio.run(run_apply_flow(ba, f"{site}/form.html", ["skip"])) + assert any("couldn't finish" in e["action"] for e in events) + assert events[-1]["done"] and not events[-1].get("submitted") + assert_no_chromium_left() + + +def test_client_disconnect_during_review_kills_the_browser(ba, site, monkeypatch): + monkeypatch.setattr(ba, "_make_browser_llm", lambda: ScriptedLLM()) + + async def run(): + gen = ba.apply_with_browser(f"{site}/form.html", {}, "", asyncio.Queue()) + async for event in gen: + if event.get("interactive"): + break + await gen.aclose() + + asyncio.run(run()) + assert_no_chromium_left() + + +def test_run_apply_closes_the_browser_as_soon_as_it_is_done(ba, site, monkeypatch): + from app import agent, tracker + monkeypatch.setattr(agent, "apply_with_browser", ba.apply_with_browser) + monkeypatch.setattr(tracker, "get_user_persona", lambda uid: {"core_info": {"first_name": "Ada"}}) + monkeypatch.setattr(ba, "_make_browser_llm", lambda: ScriptedLLM()) + + async def run(): + queue: asyncio.Queue = asyncio.Queue() + events = [] + async for chunk in agent.run_apply("u", "j", f"{site}/form.html", "", [], "s", queue, None): + event = json.loads(chunk[6:]) + events.append(event) + if event.get("interactive"): + await queue.put("skip") + return events + + final = asyncio.run(run())[-1] + assert final["type"] == "done" and final["submitted"] is False + assert_no_chromium_left() # closed by run_apply's aclosing as soon as it broke out + + +def test_production_profile_blocks_internal_targets(site, monkeypatch): + import app.browser_agent as ba_strict # IP blocking left ON + from browser_use import BrowserSession + port = site.rsplit(":", 1)[1] + + async def titles(): + session = BrowserSession(browser_profile=ba_strict._browser_profile()) + await session.start() + out = {} + for label, url in (("raw IP", f"{site}/form.html"), + ("*.localhost", f"http://windrush-test.localhost:{port}/form.html"), + ("compose service", "http://ollama:11434/")): + try: + await session.navigate_to(url) + except Exception: + pass # browser-use raises on a blocked navigation + await asyncio.sleep(1.0) + try: + out[label] = await _js(ba_strict, session, "document.title") + except Exception as exc: + out[label] = f"<{exc}>" + await session.kill() + return out + + for label, title in asyncio.run(titles()).items(): + assert "Test Application Form" not in title, label + assert_no_chromium_left() + + +def test_password_typed_via_placeholder_in_scope_and_never_sent_to_the_model(ba, site, monkeypatch): + sessions = [] + real_session = ba.BrowserSession + monkeypatch.setattr(ba, "BrowserSession", lambda *a, **k: sessions.append(real_session(*a, **k)) or sessions[-1]) + + def run(scope: str): + llm = SecretTypingLLM() + monkeypatch.setattr(ba, "_make_browser_llm", lambda: llm) + monkeypatch.setattr(ba, "_credential_scope", lambda url: scope) + typed = {} + + async def flow(): + queue: asyncio.Queue = asyncio.Queue() + async for event in ba.apply_with_browser(f"{site}/form.html", {"core_info": {"first_name": "Ada"}}, "", + queue, job_email="ada@example.com", job_password=SECRET): + if event.get("interactive"): + typed["value"] = await _js(ba, sessions[-1], "document.getElementById('pw').value") + await queue.put("skip") + if event.get("done"): + break + asyncio.run(flow()) + return llm, typed.get("value") + + llm, value = run("http*://127.0.0.1") + assert llm.typed and llm.read_back # the model read the field back with JS… + assert value == SECRET # …browser-use substituted the real value… + assert not any(SECRET in text for text in llm.seen) # …but no message (incl. a judge call) carried it + + _, value = run("https://*.some-other-site.example") # out of scope: never typed + assert value != SECRET + assert_no_chromium_left() diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000..3b8dc98 --- /dev/null +++ b/api/tests/conftest.py @@ -0,0 +1,135 @@ +""" +Shared fixtures for the API test suite. + +Several app modules read configuration at import time (JWT_SECRET, CREDENTIALS_KEY, +OLLAMA_HOST, LLM_BACKEND…), so the environment — including a fake Ollama server — is set up +here at module level, before any test imports `app`. +""" +import hashlib +import http.server +import json +import os +import tempfile +import threading +import time + +import pytest +from cryptography.fernet import Fernet + + +# ── Fake Ollama: deterministic 64-d embeddings, call accounting, latency knob ── + +def fake_vector(text: str) -> list[float]: + digest = hashlib.sha256(text.encode()).digest() * 2 + return [(b / 255.0) - 0.5 for b in digest] + + +class FakeOllama(http.server.BaseHTTPRequestHandler): + calls: list[tuple[str, int]] = [] # (endpoint, number of inputs) + delay = 0.0 + embed_404 = False # simulate an old Ollama without /api/embed + + @classmethod + def reset(cls): + cls.calls, cls.delay, cls.embed_404 = [], 0.0, False + + def log_message(self, *args): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + time.sleep(FakeOllama.delay) + if self.path == "/api/embed" and not FakeOllama.embed_404: + FakeOllama.calls.append(("embed", len(body["input"]))) + payload = {"embeddings": [fake_vector(t) for t in body["input"]]} + elif self.path == "/api/embeddings": + FakeOllama.calls.append(("embeddings", 1)) + payload = {"embedding": fake_vector(body["prompt"])} + else: + self.send_response(404) + self.end_headers() + return + data = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +_ollama = http.server.ThreadingHTTPServer(("127.0.0.1", 0), FakeOllama) +threading.Thread(target=_ollama.serve_forever, daemon=True).start() + +os.environ.update({ + "JWT_SECRET": "test-jwt-secret", + "CREDENTIALS_KEY": Fernet.generate_key().decode(), + "APP_DATA_PATH": tempfile.mkdtemp(prefix="windrush-tests-"), + "LLM_BACKEND": "groq", + "GROQ_API_KEY": "test-key", + "OLLAMA_HOST": f"http://127.0.0.1:{_ollama.server_address[1]}", + "ANONYMIZED_TELEMETRY": "false", +}) + + +# ── Fixtures ──────────────────────────────────────────────────────────────── + +@pytest.fixture +def ollama(): + """The fake Ollama handler class (inspect .calls, set .delay / .embed_404).""" + from app import semantic + FakeOllama.reset() + semantic._embedding_cache.clear() + yield FakeOllama + FakeOllama.reset() + + +@pytest.fixture +def db(tmp_path, monkeypatch): + """Fresh applications.db / jobs.db / PDF + CV dirs for one test.""" + from app import tracker, jobs_db, pdf_generator, discovery, semantic + monkeypatch.setenv("APP_DATA_PATH", str(tmp_path)) + tracker.init_db(str(tmp_path / "applications.db")) + jobs_db.init_db(str(tmp_path / "jobs.db")) + pdf_generator.init_pdf_dir(str(tmp_path)) + discovery._running.clear() + discovery._finished_at.clear() + semantic._embedding_cache.clear() + return tmp_path + + +@pytest.fixture +def make_user(db): + from app import tracker + def _make(email: str = "user@example.com") -> str: + return tracker.create_user(email, "not-a-real-hash") + return _make + + +@pytest.fixture +def user(make_user): + return make_user() + + +@pytest.fixture +def client_for(db): + """client_for(user_id) → TestClient authenticated as that user (no real JWT needed).""" + from fastapi.testclient import TestClient + from app import auth, main + + def _client(user_id: str) -> TestClient: + main.app.dependency_overrides[auth.get_current_user] = lambda: auth.User(id=user_id, email=f"{user_id}@x") + return TestClient(main.app) + + yield _client + main.app.dependency_overrides.clear() + + +@pytest.fixture +def client(client_for, user): + return client_for(user) + + +def make_job(job_id, title="Job", company=None, location="London, UK", description="", level="mid", **extra): + return {"job_id": str(job_id), "title": title, "company": company or f"Co{job_id}", "location": location, + "description": description, "url": f"https://jobs.example.com/{job_id}", "level": level, + "source": "ats", **extra} diff --git a/api/tests/test_applications.py b/api/tests/test_applications.py new file mode 100644 index 0000000..167645d --- /dev/null +++ b/api/tests/test_applications.py @@ -0,0 +1,79 @@ +"""Application tracker: ownership, and 'Applied' only on a confirmed submission.""" +import pytest + +from app import agent, main, net_guard, tracker + +JOB = {"job_id": "1", "title": "Engineer", "company": "Acme", "location": "London", "url": "https://x"} +SCORES = {"composite_score": 0, "exposure_score": 0.5, "fit_score": 0, "skill_gaps": [], "level_match": "ok"} + + +def _status(user_id, app_id): + return next(a["status"] for a in tracker.list_applications(user_id) if a["id"] == app_id) + + +def test_cannot_update_another_users_application(make_user, client_for): + alice, mallory = make_user("alice@x.com"), make_user("mallory@x.com") + app_id = tracker.add_application(alice, JOB, {}, "", SCORES) + + r = client_for(mallory).patch(f"/applications/{app_id}/status", json={"status": "Discarded", "notes": "pwned"}) + + assert r.status_code == 404 + assert _status(alice, app_id) == "Pending Review" + + +def test_owner_can_update_and_invalid_status_is_400(user, client): + app_id = tracker.add_application(user, JOB, {}, "", SCORES) + assert client.patch(f"/applications/{app_id}/status", json={"status": "Interview"}).status_code == 200 + assert _status(user, app_id) == "Interview" + assert client.patch(f"/applications/{app_id}/status", json={"status": "Bogus"}).status_code == 400 + + +@pytest.fixture +def apply_with(monkeypatch, client): + """apply_with(submitted, title) → status of the tracked application after one /apply run.""" + async def public(url): + return None + monkeypatch.setattr(net_guard, "assert_public_url", public) + + def _apply(submitted: bool, title: str, company: str = "Acme"): + async def fake_run_apply(*args, **kwargs): + yield agent._sse("start", {}) + yield agent._sse("done", {"message": "Done", "submitted": submitted}) + monkeypatch.setattr(main, "run_apply", fake_run_apply) + client.post("/apply", data={"job_id": title, "job_title": title, "company": company, + "job_url": "https://jobs.example.com/x"}) + return _apply + + +def test_apply_marks_applied_only_when_submitted(user, apply_with): + apply_with(False, "Role A") + apply_with(True, "Role B") + statuses = {a["job_title"]: a["status"] for a in tracker.list_applications(user)} + assert statuses == {"Role A": "Pending Review", "Role B": "Applied"} + + +def test_submitting_a_previously_saved_job_marks_it_applied(user, client, apply_with): + client.post("/jobs/save", json={"job": {"job_id": "9", "title": "Saved Role", "company": "Acme"}}) + apply_with(True, "Saved Role") + (row,) = [a for a in tracker.list_applications(user) if a["job_title"] == "Saved Role"] + assert row["status"] == "Applied" + + +def test_run_apply_reports_submitted_from_the_browser_session(monkeypatch): + import asyncio, json + + monkeypatch.setattr(tracker, "get_user_persona", lambda uid: {}) + + def final_event(step): + async def gen(*args, **kwargs): + yield {"action": "step", "done": False} + yield step + monkeypatch.setattr(agent, "apply_with_browser", gen) + + async def collect(): + return [json.loads(c[6:]) async for c in + agent.run_apply("u", "j", "https://x", "", [], "s", asyncio.Queue(), None)] + return [e for e in asyncio.run(collect()) if e["type"] == "done"][-1] + + assert final_event({"action": "Cancelled", "done": True})["submitted"] is False + assert final_event({"action": "ok", "done": True, "submitted": True})["submitted"] is True diff --git a/api/tests/test_browser_agent_config.py b/api/tests/test_browser_agent_config.py new file mode 100644 index 0000000..73dab5e --- /dev/null +++ b/api/tests/test_browser_agent_config.py @@ -0,0 +1,32 @@ +"""Browser-agent configuration that doesn't need a browser.""" +from importlib.metadata import version + +import pytest +from browser_use.utils import match_url_with_domain_pattern + +import app.browser_agent as ba + + +def test_pinned_browser_use_version(): + # browser_agent.py targets the 0.11 CDP API — upgrading needs a deliberate port + `pytest -m browser`. + assert version("browser-use") == "0.11.13" + + +@pytest.mark.parametrize("backend, provider", [("claude", "anthropic"), ("groq", "groq"), ("ollama", "ollama")]) +def test_llm_factory_uses_browser_use_native_wrappers(monkeypatch, backend, provider): + monkeypatch.setattr(ba, "_BACKEND", backend) + assert ba._make_browser_llm().provider == provider + + +def test_credential_scope_is_the_job_host_over_https(): + scope = ba._credential_scope("https://www.boards.greenhouse.io/acme/jobs/1") + assert scope == "https://*.boards.greenhouse.io" + assert match_url_with_domain_pattern("https://boards.greenhouse.io/acme/login", scope) + assert not match_url_with_domain_pattern("https://evil.example/x", scope) + assert not match_url_with_domain_pattern("http://boards.greenhouse.io/x", scope) + + +def test_task_carries_the_placeholder_not_the_password(): + task = ba._build_task("https://x.example/job", {"core_info": {"first_name": "Ada"}}, "", "a@x.com", "hunter2", "") + assert "hunter2" not in task and "+ {committedQuery || selectedTags.length > 0 || committedLocation || level + ? "Precision search results" + : "AI-ranked opportunities for your profile"} +
+- {committedQuery || selectedTags.length > 0 || committedLocation || level - ? "Precision search results" - : "AI-ranked opportunities for your profile"} -
+ + Analyze a link +Paste a job-posting URL
++ We'll read the listing and run the same fit analysis, tailored CV, and apply flow as feed jobs. +
+ + {linkError &&{linkError}
} +- No jobs found. Try adjusting your search or filters. -
+ {discovering ? ( ++ + Searching live job boards for matches — new roles will appear here shortly… +
+ ) : ( ++ No jobs found. Try adjusting your search or filters. +
+ )}+
{jobs.length} job{jobs.length !== 1 ? "s" : ""} {hasMore ? "+" : ""} + {discovering && ( + + + searching live job boards for more… + + )}