mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 13:32:22 +02:00
273 lines
12 KiB
Python
273 lines
12 KiB
Python
"""Onboarding v3 endpoints: identity decode, local scan, prep fail-open."""
|
|
|
|
import base64
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.apps.onboarding.identity import build_identity, decode_jwt_payload
|
|
from backend.apps.onboarding.local_scan import detect_signal_apps, run_local_scan
|
|
from backend.apps.onboarding.models import PrepRequest, ScanResult
|
|
from backend.apps.onboarding.prep import FALLBACK_STARTERS, build_prep, parse_prep
|
|
from backend.apps.settings.models import AppSettings
|
|
|
|
|
|
def make_jwt(payload: dict) -> str:
|
|
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
|
return f"header.{body}.sig"
|
|
|
|
|
|
def test_decode_jwt_payload_roundtrip():
|
|
claims = {"email": "eric@example.com", "https://api.openai.com/auth": {"chatgpt_plan_type": "pro"}}
|
|
assert decode_jwt_payload(make_jwt(claims)) == claims
|
|
|
|
|
|
def test_decode_jwt_payload_garbage_is_empty():
|
|
assert decode_jwt_payload("") == {}
|
|
assert decode_jwt_payload("not-a-jwt") == {}
|
|
assert decode_jwt_payload("a.b.c") == {}
|
|
|
|
|
|
def test_build_identity_codex_plan_and_email():
|
|
token = make_jwt({"email": "eric@example.com", "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}})
|
|
rows = [
|
|
{"provider": "codex", "isActive": True, "idToken": token},
|
|
{"provider": "gemini-cli", "isActive": True, "email": "eric@gmail.com"},
|
|
{"provider": "claude", "isActive": True, "name": "Account 1"},
|
|
{"provider": "codex", "isActive": False, "idToken": token},
|
|
{"provider": "openrouter", "isActive": True},
|
|
]
|
|
result = build_identity(rows)
|
|
by_provider = {p.provider: p for p in result.providers}
|
|
assert set(by_provider) == {"codex", "gemini-cli", "claude"}
|
|
assert by_provider["codex"].email == "eric@example.com"
|
|
assert by_provider["codex"].plan == "plus"
|
|
assert by_provider["gemini-cli"].email == "eric@gmail.com"
|
|
assert by_provider["claude"].email is None
|
|
|
|
|
|
def test_build_identity_bad_rows_never_raise():
|
|
rows = [{"provider": "codex", "isActive": True, "idToken": 12345}, {"isActive": True}, {}]
|
|
result = build_identity(rows)
|
|
assert [p.provider for p in result.providers] == ["codex"]
|
|
assert result.providers[0].email is None
|
|
|
|
|
|
def test_run_local_scan_counts_names_only(tmp_path: Path):
|
|
downloads = tmp_path / "Downloads"
|
|
downloads.mkdir()
|
|
(downloads / "Screenshot 2026-07-01.png").write_text("x")
|
|
(downloads / "Screen Shot old.png").write_text("x")
|
|
(downloads / "paper.pdf").write_text("x")
|
|
(downloads / ".hidden").write_text("x")
|
|
(tmp_path / ".gitconfig").write_text("[user]")
|
|
repos = tmp_path / "dev"
|
|
(repos / "proj1" / ".git").mkdir(parents=True)
|
|
(repos / "not-a-repo").mkdir()
|
|
result = run_local_scan(tmp_path)
|
|
downloads_summary = next(f for f in result.folders if f.name == "Downloads")
|
|
assert downloads_summary.entry_count == 3
|
|
assert downloads_summary.screenshot_count == 2
|
|
assert "png" in downloads_summary.top_extensions and "pdf" in downloads_summary.top_extensions
|
|
desktop_summary = next(f for f in result.folders if f.name == "Desktop")
|
|
assert desktop_summary.entry_count == 0
|
|
assert result.git_repo_count == 1
|
|
assert result.has_gitconfig is True
|
|
serialized = json.dumps(result.model_dump())
|
|
assert "[user]" not in serialized
|
|
|
|
|
|
def test_signal_apps_picks_tools_not_noise_and_avoids_substring_traps():
|
|
apps = ["Calculator", "Xcode", "Visual Studio Code", "Figma", "Search", "System Settings", "Adobe Photoshop 2024", "Arc"]
|
|
signal = detect_signal_apps(apps)
|
|
assert "Xcode" in signal and "Visual Studio Code" in signal and "Figma" in signal
|
|
assert "Adobe Photoshop 2024" in signal # prefix match on a versioned name
|
|
assert "Arc" in signal
|
|
# "Search" contains "arc" as a substring but must NOT be treated as the Arc browser.
|
|
assert "Search" not in signal
|
|
assert "Calculator" not in signal and "System Settings" not in signal
|
|
|
|
|
|
def test_parse_prep_strict_and_lenient():
|
|
good = '{"greeting": "Hey!", "starters": [{"title": "A", "prompt": "do a"}, {"title": "B", "prompt": "do b"}]}'
|
|
parsed = parse_prep(f"Sure! Here you go: {good}")
|
|
assert parsed is not None
|
|
assert parsed.greeting == "Hey!"
|
|
assert [s.title for s in parsed.starters] == ["A", "B"]
|
|
# Reason is optional; missing reason defaults to empty, never drops the starter.
|
|
assert parsed.starters[0].reason == ""
|
|
assert parse_prep("no json here") is None
|
|
assert parse_prep('{"greeting": "hi", "starters": []}') is None
|
|
|
|
|
|
def test_parse_prep_carries_reasons():
|
|
rich = (
|
|
'{"greeting": "Hey!", "app_title": "Lift Log", "app_prompt": "Build me a lifting tracker", '
|
|
'"app_reason": "you plan lifts with ChatGPT daily", '
|
|
'"starters": [{"title": "Audit Downloads", "prompt": "audit it", "reason": "1,305 files piling up there"}]}'
|
|
)
|
|
parsed = parse_prep(rich)
|
|
assert parsed is not None
|
|
assert parsed.starters[0].reason == "1,305 files piling up there"
|
|
assert parsed.app_reason == "you plan lifts with ChatGPT daily"
|
|
|
|
|
|
def test_summarize_chatgpt_usage_leads_with_memory_and_caps():
|
|
from backend.apps.onboarding.usage.chatgpt_usage import TOTAL_CONVO_CHARS, summarize_chatgpt_usage
|
|
|
|
s = summarize_chatgpt_usage(
|
|
812,
|
|
["Has an Akita", "Squats 495"],
|
|
["Swift concurrency", "Deadlift form"],
|
|
["User: fix my squat form?\nAssistant: brace harder."],
|
|
)
|
|
assert "812 past AI conversations" in s
|
|
assert "Has an Akita; Squats 495" in s
|
|
assert "Swift concurrency; Deadlift form" in s
|
|
assert "fix my squat form?" in s
|
|
big = summarize_chatgpt_usage(
|
|
1000,
|
|
[],
|
|
[f"t{i}x" for i in range(1000)],
|
|
["c" * 60000 for _ in range(10)],
|
|
)
|
|
assert "t149x" in big and "t150x" not in big
|
|
convo_block = big.split("real asks + the exchange")[1]
|
|
assert len(convo_block) <= TOTAL_CONVO_CHARS + 10000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_harvest_chatgpt_usage_fails_open_without_codex(monkeypatch):
|
|
from backend.apps.onboarding.usage import chatgpt_usage
|
|
|
|
monkeypatch.setattr(chatgpt_usage, "read_persisted_connections", lambda: [])
|
|
assert await chatgpt_usage.harvest_chatgpt_usage() == ""
|
|
|
|
|
|
def test_read_provider_cookies_fails_open_without_a_store(monkeypatch):
|
|
from backend.apps.onboarding.usage import browser_cookies
|
|
|
|
# No browser store has the domain -> empty jar/records, and the keychain is never touched.
|
|
monkeypatch.setattr(browser_cookies, "p_best_store", lambda domain: None)
|
|
assert browser_cookies.read_provider_cookies("claude.ai") == {}
|
|
assert browser_cookies.read_provider_cookie_records("claude.ai") == []
|
|
|
|
|
|
def test_win_storage_key_parses_local_state_and_unwraps(monkeypatch, tmp_path):
|
|
from backend.apps.onboarding.usage import browser_cookies
|
|
|
|
# Local State carries a base64 "DPAPI"-prefixed key; the Windows path strips the prefix and
|
|
# hands the rest to CryptUnprotectData. Prove the parse + prefix-strip without needing Windows.
|
|
raw_key = b"DPAPI" + b"wrapped-key-bytes"
|
|
local_state_dir = tmp_path / "UserData"
|
|
local_state_dir.mkdir()
|
|
(local_state_dir / "Local State").write_text(
|
|
json.dumps({"os_crypt": {"encrypted_key": base64.b64encode(raw_key).decode()}})
|
|
)
|
|
monkeypatch.setattr(browser_cookies, "CHROMIUM_ROOTS", {"Chrome": str(local_state_dir)})
|
|
seen = {}
|
|
|
|
def fake_unprotect(data: bytes):
|
|
seen["passed"] = data
|
|
return b"unwrapped-aes-key"
|
|
|
|
monkeypatch.setattr(browser_cookies, "p_win_dpapi_unprotect", fake_unprotect)
|
|
key = browser_cookies.p_win_storage_key("Chrome")
|
|
assert key == b"unwrapped-aes-key"
|
|
assert seen["passed"] == b"wrapped-key-bytes" # the 5-byte "DPAPI" prefix was stripped
|
|
|
|
|
|
def test_win_storage_key_fails_open(monkeypatch, tmp_path):
|
|
from backend.apps.onboarding.usage import browser_cookies
|
|
|
|
# Missing Local State, malformed JSON, and DPAPI failure all fail open to None (-> scan fallback).
|
|
monkeypatch.setattr(browser_cookies, "CHROMIUM_ROOTS", {"Chrome": str(tmp_path)})
|
|
assert browser_cookies.p_win_storage_key("Chrome") is None # no Local State file
|
|
assert browser_cookies.p_win_storage_key("Nonexistent") is None
|
|
|
|
|
|
def test_decrypt_rejects_app_bound_v20():
|
|
from backend.apps.onboarding.usage import browser_cookies
|
|
|
|
# v20 = app-bound encryption (modern Chrome), out of reach on both OSes -> None, never a crash.
|
|
assert browser_cookies.p_decrypt(b"v20" + b"anything", b"\x00" * 32) is None
|
|
assert browser_cookies.p_decrypt(b"", b"\x00" * 32) is None
|
|
|
|
|
|
def test_dump_cookies_only_serves_allowlisted_domains(monkeypatch, capsys):
|
|
from backend.apps.onboarding.usage import dump_cookies
|
|
|
|
# Patch the names in dump_cookies' own namespace, so a real read (+ keychain) never fires.
|
|
monkeypatch.setattr(dump_cookies, "read_provider_cookie_records", lambda domain: [{"name": "x", "value": "y"}])
|
|
monkeypatch.setattr(dump_cookies, "read_google_session_records", lambda: [{"name": "SID", "value": "g"}])
|
|
# An off-list domain must never trigger a read, prints [].
|
|
monkeypatch.setattr("sys.argv", ["dump_cookies", "evil.example.com"])
|
|
dump_cookies.main()
|
|
assert capsys.readouterr().out == "[]"
|
|
# An allowlisted domain passes through to the reader.
|
|
monkeypatch.setattr("sys.argv", ["dump_cookies", "claude.ai"])
|
|
dump_cookies.main()
|
|
assert '"name": "x"' in capsys.readouterr().out
|
|
# Gemini routes to the SCOPED google reader, not a raw gemini.google.com read.
|
|
monkeypatch.setattr("sys.argv", ["dump_cookies", "gemini.google.com"])
|
|
dump_cookies.main()
|
|
assert '"name": "SID"' in capsys.readouterr().out
|
|
|
|
|
|
def test_read_google_session_records_scopes_to_named_auth_cookies(monkeypatch):
|
|
from backend.apps.onboarding.usage import browser_cookies
|
|
|
|
seen_domain = {}
|
|
|
|
def fake_records(domain: str):
|
|
seen_domain["d"] = domain
|
|
return [
|
|
{"name": "SID", "value": "a"},
|
|
{"name": "__Secure-1PSID", "value": "b"},
|
|
{"name": "SEARCH_SAMESITE", "value": "c"}, # non-auth google cookie
|
|
{"name": "OTZ", "value": "d"}, # non-auth google cookie
|
|
]
|
|
|
|
monkeypatch.setattr(browser_cookies, "read_provider_cookie_records", fake_records)
|
|
recs = browser_cookies.read_google_session_records()
|
|
# Reads the parent SSO domain, then keeps ONLY the named auth cookies (never a full sweep).
|
|
assert seen_domain["d"] == ".google.com"
|
|
assert {r["name"] for r in recs} == {"SID", "__Secure-1PSID"}
|
|
|
|
|
|
def test_summarize_claude_usage_counts_and_caps():
|
|
from backend.apps.onboarding.usage.claude_usage import TOTAL_CONVO_CHARS, summarize_claude_usage
|
|
|
|
s = summarize_claude_usage(
|
|
490,
|
|
["Yuji Itadori and Buddhism", "B2B SaaS Startup Ideas"],
|
|
["User: pitch me a startup\nAssistant: sure."],
|
|
)
|
|
assert "490 past Claude conversations" in s
|
|
assert "Yuji Itadori and Buddhism; B2B SaaS Startup Ideas" in s
|
|
assert "pitch me a startup" in s
|
|
big = summarize_claude_usage(1000, [f"t{i}x" for i in range(1000)], ["c" * 60000 for _ in range(10)])
|
|
assert "t149x" in big and "t150x" not in big
|
|
convo_block = big.split("real asks + the exchange")[1]
|
|
assert len(convo_block) <= TOTAL_CONVO_CHARS + 10000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_harvest_claude_usage_fails_open_without_cookies(monkeypatch):
|
|
from backend.apps.onboarding.usage import claude_usage
|
|
|
|
monkeypatch.setattr(claude_usage, "read_provider_cookies", lambda domain: {})
|
|
assert await claude_usage.harvest_claude_usage() == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_prep_fails_open_without_provider(monkeypatch):
|
|
async def boom(*args, **kwargs):
|
|
raise ValueError("no provider connected")
|
|
|
|
monkeypatch.setattr("backend.apps.agents.providers.registry.resolve_aux_model", boom)
|
|
result = await build_prep(AppSettings(), PrepRequest(scan=ScanResult(), picked_apps=["notion"]))
|
|
assert result.greeting == ""
|
|
assert result.starters == FALLBACK_STARTERS
|