diff --git a/backend/database.py b/backend/database.py index e4682564..b9c58ed5 100644 --- a/backend/database.py +++ b/backend/database.py @@ -222,3 +222,21 @@ def delete_profile(profile_id: str) -> bool: cursor = conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,)) conn.commit() return cursor.rowcount > 0 + + +def reset_profile(profile_id: str) -> dict[str, Any] | None: + """Re-roll fingerprint_seed and update timestamp. Returns updated profile or None.""" + existing = get_profile(profile_id) + if not existing: + return None + + new_seed = random.randint(10000, 99999) + now = _now() + with get_db() as conn: + conn.execute( + "UPDATE profiles SET fingerprint_seed = ?, updated_at = ? WHERE id = ?", + (new_seed, now, profile_id), + ) + conn.commit() + + return get_profile(profile_id) diff --git a/backend/main.py b/backend/main.py index 92727a53..244d02b3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -519,6 +519,75 @@ async def delete_profile(profile_id: str): return {"ok": True} +@app.post("/api/profiles/{profile_id}/reset", response_model=ProfileResponse) +async def reset_profile(profile_id: str): + """Reset a profile: stop browser, wipe state files, re-roll fingerprint seed. + + Preserves bookmarks, preferences, and all profile settings (name, proxy, tags, etc.). + """ + profile = db.get_profile(profile_id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + # Stop browser if running + if profile_id in browser_mgr.running: + await browser_mgr.stop(profile_id) + + # Wipe browser state files (cookies, history, cache, etc.) + # Preserve Bookmarks and Preferences (search engine config) + user_data_dir = Path(profile["user_data_dir"]) + default_dir = user_data_dir / "Default" + if default_dir.exists(): + _STATE_FILES = [ + "Cookies", "Cookies-journal", + "History", "History-journal", "History Provider Cache", + "Login Data", "Login Data-journal", + "Web Data", "Web Data-journal", + "Favicons", "Favicons-journal", + "Shortcuts", "Shortcuts-journal", + "Top Sites", "Top Sites-journal", + "Visited Links", + "Network Action Predictor", + "Network Action Predictor-journal", + "TransportSecurity", + "SecurePreferences", + "Current Session", "Current Tabs", + "Last Session", "Last Tabs", + "affiliation_db", "coupon_db", + "DownloadMetadata", + "autofill_regex_whitelist.json", + ] + _STATE_DIRS = [ + "Cache", "Code Cache", "GPUCache", + "Service Worker", "Service Worker/CacheStorage", + "Local Storage", "Session Storage", + "IndexedDB", "databases", + "blob_storage", + "File System", + "GCM Store", + "Extension Rules", "Extension Scripts", "Extension State", + "Platform Notifications", + ] + for fname in _STATE_FILES: + (default_dir / fname).unlink(missing_ok=True) + for dname in _STATE_DIRS: + dirpath = default_dir / dname + if dirpath.exists(): + shutil.rmtree(dirpath, ignore_errors=True) + + # Re-roll fingerprint seed in DB + updated = db.reset_profile(profile_id) + if not updated: + raise HTTPException(status_code=500, detail="Failed to reset profile") + + status = browser_mgr.get_status(profile_id) + updated["status"] = status["status"] + updated["vnc_ws_port"] = status["vnc_ws_port"] + updated["cdp_url"] = status["cdp_url"] + updated["tags"] = [TagResponse(**t) for t in updated.get("tags", [])] + return ProfileResponse(**updated) + + # ── Launch / Stop ───────────────────────────────────────────────────────────── diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9b2bca33..fbc38471 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -10,6 +10,7 @@ from backend import main from backend.browser_manager import RunningProfile +from pathlib import Path # ── Profile CRUD ───────────────────────────────────────────────────────────── @@ -560,3 +561,104 @@ def test_ws_allows_no_origin(app_client: TestClient): except Exception as exc: assert "4403" not in str(exc) main.browser_mgr.running.pop(pid, None) + + +# ── Profile Reset ──────────────────────────────────────────────────────────── + + +def test_reset_profile_not_found(app_client: TestClient): + resp = app_client.post("/api/profiles/nonexistent/reset") + assert resp.status_code == 404 + + +def test_reset_profile_returns_updated_profile(app_client: TestClient): + create = app_client.post("/api/profiles", json={"name": "ResetTest", "fingerprint_seed": 99999}) + pid = create.json()["id"] + old_seed = create.json()["fingerprint_seed"] + + resp = app_client.post(f"/api/profiles/{pid}/reset") + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "ResetTest" + assert data["fingerprint_seed"] != old_seed + assert data["status"] == "stopped" + assert "id" in data + + +def test_reset_profile_preserves_tags(app_client: TestClient): + create = app_client.post("/api/profiles", json={ + "name": "ResetTags", + "tags": [{"tag": "work", "color": "#ff0000"}], + }) + pid = create.json()["id"] + + resp = app_client.post(f"/api/profiles/{pid}/reset") + assert resp.status_code == 200 + assert len(resp.json()["tags"]) == 1 + assert resp.json()["tags"][0]["tag"] == "work" + + +def test_reset_profile_stops_running(app_client: TestClient): + create = app_client.post("/api/profiles", json={"name": "ResetRunning"}) + pid = create.json()["id"] + + # Inject mock running profile + mock_running = MagicMock(spec=RunningProfile) + mock_running.display = 100 + mock_running.ws_port = 6100 + mock_running.cdp_port = 5100 + main.browser_mgr.running[pid] = mock_running + main.browser_mgr.stop = AsyncMock() + + resp = app_client.post(f"/api/profiles/{pid}/reset") + assert resp.status_code == 200 + main.browser_mgr.stop.assert_called_once_with(pid) + + +def test_reset_profile_wipes_state_files(app_client: TestClient, tmp_path: Path): + create = app_client.post("/api/profiles", json={"name": "ResetWipe"}) + pid = create.json()["id"] + + # Get the profile's user_data_dir + profile = app_client.get(f"/api/profiles/{pid}").json() + default_dir = Path(profile["user_data_dir"]) / "Default" + default_dir.mkdir(parents=True, exist_ok=True) + + # Create fake state files + (default_dir / "Cookies").write_text("fake cookies") + (default_dir / "History").write_text("fake history") + cache_dir = default_dir / "Cache" + cache_dir.mkdir() + (cache_dir / "data").write_text("cached") + + # Create preserved files + (default_dir / "Bookmarks").write_text("{}") + (default_dir / "Preferences").write_text("{}") + + resp = app_client.post(f"/api/profiles/{pid}/reset") + assert resp.status_code == 200 + + # State files should be deleted + assert not (default_dir / "Cookies").exists() + assert not (default_dir / "History").exists() + assert not cache_dir.exists() + + # Preserved files should still exist + assert (default_dir / "Bookmarks").exists() + assert (default_dir / "Preferences").exists() + + +def test_reset_profile_keeps_bookmarks(app_client: TestClient): + create = app_client.post("/api/profiles", json={"name": "ResetBM"}) + pid = create.json()["id"] + + profile = app_client.get(f"/api/profiles/{pid}").json() + default_dir = Path(profile["user_data_dir"]) / "Default" + default_dir.mkdir(parents=True, exist_ok=True) + (default_dir / "Bookmarks").write_text('{"test": true}') + (default_dir / "Preferences").write_text('{"test": true}') + + resp = app_client.post(f"/api/profiles/{pid}/reset") + assert resp.status_code == 200 + assert (default_dir / "Bookmarks").read_text() == '{"test": true}' + assert (default_dir / "Preferences").read_text() == '{"test": true}' \ No newline at end of file diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index ef292c5b..914a200a 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -236,3 +236,47 @@ def test_delete_profile_cascades_tags(tmp_db: Path): "SELECT * FROM profile_tags WHERE profile_id = ?", (p["id"],) ).fetchall() assert len(rows) == 0 + + +# ── reset_profile ──────────────────────────────────────────────────────────── + + +def test_reset_profile_changes_seed(tmp_db: Path): + p = db.create_profile("ResetMe", fingerprint_seed=11111) + result = db.reset_profile(p["id"]) + assert result is not None + assert result["fingerprint_seed"] != 11111 + assert 10000 <= result["fingerprint_seed"] <= 99999 + + +def test_reset_profile_preserves_name(tmp_db: Path): + p = db.create_profile("KeepName", fingerprint_seed=22222) + result = db.reset_profile(p["id"]) + assert result["name"] == "KeepName" + + +def test_reset_profile_preserves_proxy(tmp_db: Path): + p = db.create_profile("KeepProxy", proxy="http://proxy:8080") + result = db.reset_profile(p["id"]) + assert result["proxy"] == "http://proxy:8080" + + +def test_reset_profile_preserves_tags(tmp_db: Path): + p = db.create_profile("KeepTags", tags=[{"tag": "work", "color": "#ff0000"}]) + result = db.reset_profile(p["id"]) + assert len(result["tags"]) == 1 + assert result["tags"][0]["tag"] == "work" + + +def test_reset_profile_updates_timestamp(tmp_db: Path): + p = db.create_profile("Timestamp") + old_updated = p["updated_at"] + old_created = p["created_at"] + import time; time.sleep(0.01) + result = db.reset_profile(p["id"]) + assert result["updated_at"] > old_updated + assert result["created_at"] == old_created + + +def test_reset_profile_not_found(tmp_db: Path): + assert db.reset_profile("nonexistent") is None diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fb502009..6acdb105 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -139,6 +139,9 @@ export const api = { deleteProfile: (id: string) => request<{ ok: boolean }>(`/api/profiles/${id}`, { method: "DELETE" }), + resetProfile: (id: string) => + request(`/api/profiles/${id}/reset`, { method: "POST" }), + launchProfile: (id: string) => request(`/api/profiles/${id}/launch`, { method: "POST" }),