feat: harden remote serve and reuse connections
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels. - Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips. - Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely. - Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows. - Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0. - Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
This commit is contained in:
+35
-29
@@ -8,45 +8,51 @@ They are automatically skipped if the native host socket is not reachable.
|
||||
import time
|
||||
import pytest
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.remote import pool as _remote_pool
|
||||
|
||||
TEST_BROWSER_PROFILE = "testing"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_remote_pool():
|
||||
"""Close any pooled remote connections between tests so a connection opened
|
||||
against one test's throwaway server can't leak into the next."""
|
||||
yield
|
||||
_remote_pool.close_all()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser():
|
||||
"""Returns a connected send_command callable for the testing profile, or skips the test."""
|
||||
try:
|
||||
send_command("tabs.list", profile=TEST_BROWSER_PROFILE)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
pytest.skip(
|
||||
f"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile ({e})"
|
||||
)
|
||||
"""Returns a connected send_command callable for the testing profile, or skips the test."""
|
||||
try:
|
||||
send_command("tabs.list", profile=TEST_BROWSER_PROFILE)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
pytest.skip(
|
||||
f"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile ({e})"
|
||||
)
|
||||
|
||||
def _browser(command, args=None):
|
||||
return send_command(command, args, profile=TEST_BROWSER_PROFILE)
|
||||
|
||||
return _browser
|
||||
def _browser(command, args=None):
|
||||
return send_command(command, args, profile=TEST_BROWSER_PROFILE)
|
||||
|
||||
return _browser
|
||||
|
||||
@pytest.fixture()
|
||||
def http_tab(browser):
|
||||
"""Opens a dedicated http/https tab for the current test and returns its tab info."""
|
||||
created = browser("navigate.open", {"url": "https://example.com", "background": True})
|
||||
tab_id = created["id"]
|
||||
"""Opens a dedicated http/https tab for the current test and returns its tab info."""
|
||||
created = browser("navigate.open", {"url": "https://example.com", "background": True})
|
||||
tab_id = created["id"]
|
||||
|
||||
tab = None
|
||||
tab = None
|
||||
try:
|
||||
for _ in range(30):
|
||||
tabs = browser("tabs.list")
|
||||
tab = next((t for t in tabs if t.get("id") == tab_id and t.get("url", "").startswith("http")), None)
|
||||
if tab is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
if tab is None:
|
||||
pytest.skip("Dedicated http/https test tab did not finish loading")
|
||||
yield tab
|
||||
finally:
|
||||
try:
|
||||
for _ in range(30):
|
||||
tabs = browser("tabs.list")
|
||||
tab = next((t for t in tabs if t.get("id") == tab_id and t.get("url", "").startswith("http")), None)
|
||||
if tab is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
if tab is None:
|
||||
pytest.skip("Dedicated http/https test tab did not finish loading")
|
||||
yield tab
|
||||
finally:
|
||||
try:
|
||||
browser("tabs.close", {"tabId": tab_id})
|
||||
except Exception:
|
||||
pass
|
||||
browser("tabs.close", {"tabId": tab_id})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+16
-10
@@ -32,6 +32,12 @@ GROUP_DATA = {
|
||||
"tabCount": 3,
|
||||
}
|
||||
|
||||
def tab_close_args(tab_id: int):
|
||||
return {"tabId": tab_id, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"}
|
||||
|
||||
def group_close_args(group_id: int):
|
||||
return {"groupId": group_id, "gentleMode": "auto"}
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_send():
|
||||
"""Patch send_command for the duration of one test.
|
||||
@@ -454,7 +460,7 @@ class TestTabs:
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="default"),
|
||||
call("tabs.list", {}, profile="work"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
|
||||
call("tabs.close", tab_close_args(11), profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
@@ -473,7 +479,7 @@ class TestTabs:
|
||||
assert [tab.browser for tab in tabs] == ["work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
|
||||
call("tabs.close", tab_close_args(10), profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_list_remote_bound_actions_preserve_key(self, mock_send):
|
||||
@@ -488,7 +494,7 @@ class TestTabs:
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("tabs.close", tab_close_args(10), profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_list_browser_host_alias_fans_out_to_remote_targets(self, mock_send):
|
||||
@@ -510,7 +516,7 @@ class TestTabs:
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.close", tab_close_args(11), profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_active_returns_active_tab(self, b, mock_send):
|
||||
@@ -690,7 +696,7 @@ class TestGroups:
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="default"),
|
||||
call("group.list", {}, profile="work"),
|
||||
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
|
||||
call("group.close", group_close_args(99), profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
@@ -709,7 +715,7 @@ class TestGroups:
|
||||
assert [group.browser for group in groups] == ["work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
|
||||
call("group.close", group_close_args(42), profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_group_list_remote_bound_actions_preserve_key(self, mock_send):
|
||||
@@ -724,7 +730,7 @@ class TestGroups:
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("group.close", group_close_args(42), profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
@@ -954,7 +960,7 @@ class TestTabModel:
|
||||
|
||||
def test_close(self, tab, mock_send):
|
||||
tab.close()
|
||||
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.close", tab_close_args(10), profile=None, remote=None, key=None)
|
||||
|
||||
def test_activate(self, tab, mock_send):
|
||||
tab.activate()
|
||||
@@ -1043,7 +1049,7 @@ class TestGroupModel:
|
||||
|
||||
def test_close(self, group, mock_send):
|
||||
group.close()
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
mock_send.assert_called_once_with("group.close", group_close_args(42), profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs(self, group, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
@@ -1115,7 +1121,7 @@ class TestSDKDecorators:
|
||||
remote=None,
|
||||
key=None,
|
||||
),
|
||||
call("tabs.close", {"tabId": 123}, profile=None, remote=None, key=None),
|
||||
call("tabs.close", tab_close_args(123), profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_wait_for_selector_runs_before_function_and_can_inject_result(self, b, mock_send):
|
||||
|
||||
+39
-17
@@ -28,8 +28,8 @@ def test_long_version_option():
|
||||
assert result.output.strip() == _expected_version()
|
||||
|
||||
def test_project_version_falls_back_to_installed_package_metadata():
|
||||
with patch("browser_cli.cli.Path.read_text", side_effect=OSError), patch(
|
||||
"browser_cli.cli.package_version", return_value="9.9.9"
|
||||
with patch("browser_cli.version_manager.Path.read_text", side_effect=OSError), patch(
|
||||
"browser_cli.version_manager._pkg_version", return_value="9.9.9"
|
||||
):
|
||||
assert _project_version() == "9.9.9"
|
||||
|
||||
@@ -114,8 +114,8 @@ def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
|
||||
],
|
||||
}
|
||||
]
|
||||
assert "Testing extension ID" in result.output
|
||||
assert "Chrome Web Store extension ID" in result.output
|
||||
assert "chromewebstore.google.com" in result.output
|
||||
assert "Add to Brave" in result.output
|
||||
|
||||
def test_install_writes_firefox_allowed_extensions(tmp_path):
|
||||
manifests = []
|
||||
@@ -139,12 +139,34 @@ def test_install_writes_firefox_allowed_extensions(tmp_path):
|
||||
"allowed_extensions": ["browser-cli@yiprawr.dev"],
|
||||
}
|
||||
]
|
||||
assert "addons.mozilla.org/firefox/addon/browser-cli" in result.output
|
||||
assert "Add to Firefox" in result.output
|
||||
|
||||
def test_install_dev_flag_prints_unpacked_instructions(tmp_path):
|
||||
with patch("browser_cli.commands.install.native_host_exe", return_value=tmp_path / "browser-cli-native-host"), patch(
|
||||
"browser_cli.commands.install.write_native_host_exe"
|
||||
), patch("browser_cli.commands.install._install_manifest", return_value=[tmp_path / "com.browsercli.host.json"]):
|
||||
result = CliRunner().invoke(main, ["install", "brave", "--dev"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Load unpacked" in result.output
|
||||
assert "Developer mode" in result.output
|
||||
assert "Testing extension ID" in result.output
|
||||
assert "Chrome Web Store extension ID" in result.output
|
||||
assert "chromewebstore.google.com" not in result.output # store path is the non-dev default
|
||||
|
||||
def test_install_dev_flag_prints_firefox_unpacked_instructions(tmp_path):
|
||||
with patch("browser_cli.commands.install.native_host_exe", return_value=tmp_path / "browser-cli-native-host"), patch(
|
||||
"browser_cli.commands.install.write_native_host_exe"
|
||||
), patch("browser_cli.commands.install._install_manifest", return_value=[tmp_path / "com.browsercli.host.json"]):
|
||||
result = CliRunner().invoke(main, ["install", "firefox", "--dev"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "about:debugging#/runtime/this-firefox" in result.output
|
||||
assert "npm run package:extension:firefox" in result.output
|
||||
output_unwrapped = result.output.replace("\n", "")
|
||||
assert "dist/extension-package-firefox/manifest.json" in output_unwrapped
|
||||
assert "Do not select extension/manifest.json" in output_unwrapped
|
||||
assert "Firefox extension ID" in result.output
|
||||
|
||||
def test_install_windows_registers_native_host(tmp_path):
|
||||
writes = []
|
||||
@@ -205,7 +227,7 @@ def test_write_native_host_exe_windows(tmp_path):
|
||||
|
||||
def test_clients_exits_cleanly_when_registry_is_missing():
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
|
||||
"browser_cli.commands.clients.active_browser_targets", return_value=[]
|
||||
"browser_cli.client.core.active_browser_targets", return_value=[]
|
||||
):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
@@ -239,8 +261,8 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
|
||||
return [remote_target]
|
||||
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.commands.clients.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.commands.clients.active_browser_targets", side_effect=fake_active_browser_targets) as active_targets:
|
||||
"browser_cli.client.core.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.client.core.active_browser_targets", side_effect=fake_active_browser_targets) as active_targets:
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -263,8 +285,8 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
||||
return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}]
|
||||
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.commands.clients.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.commands.clients.active_browser_targets", return_value=[]):
|
||||
"browser_cli.client.core.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.client.core.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -280,7 +302,7 @@ def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
|
||||
), patch("browser_cli.commands.clients.send_command", side_effect=fake_send_command) as send_command:
|
||||
), patch("browser_cli.client.core.send_command", side_effect=fake_send_command) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -290,7 +312,7 @@ def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
assert "2.3.4" in result.output
|
||||
|
||||
def test_clients_remote_respects_global_browser_route():
|
||||
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.commands.clients.send_command", return_value=[]) as send_command:
|
||||
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.client.core.send_command", return_value=[]) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -316,10 +338,10 @@ def test_clients_browser_alias_resolves_to_remote():
|
||||
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.commands.clients.remote_target_for_alias", return_value=resolved_target
|
||||
"browser_cli.client.core.remote_target_for_alias", return_value=resolved_target
|
||||
), patch(
|
||||
"browser_cli.commands.clients.remote_browser_targets", return_value=all_remote_targets
|
||||
), patch("browser_cli.commands.clients.send_command", side_effect=fake_send_command) as send_command:
|
||||
"browser_cli.client.core.remote_browser_targets", return_value=all_remote_targets
|
||||
), patch("browser_cli.client.core.send_command", side_effect=fake_send_command) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "browser-host.example", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -346,8 +368,8 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||
return responses[profile]
|
||||
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.commands.clients.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.commands.clients.active_browser_targets", return_value=[]):
|
||||
"browser_cli.client.core.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.client.core.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
+518
-393
File diff suppressed because it is too large
Load Diff
+295
-275
@@ -9,412 +9,432 @@ from browser_cli import framing, local_transport
|
||||
from browser_cli.native import local_server, protocol as native_protocol
|
||||
|
||||
def _raise_system_exit(code: int):
|
||||
raise SystemExit(code)
|
||||
raise SystemExit(code)
|
||||
|
||||
def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
|
||||
alias = "work"
|
||||
socket_path = tmp_path / "work.sock"
|
||||
socket_path.write_text("")
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
|
||||
alias = "work"
|
||||
socket_path = tmp_path / "work.sock"
|
||||
socket_path.write_text("")
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
|
||||
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: False)
|
||||
|
||||
native_host._cleanup(alias)
|
||||
native_host._cleanup(alias)
|
||||
|
||||
assert not socket_path.exists()
|
||||
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
|
||||
assert not socket_path.exists()
|
||||
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
|
||||
|
||||
def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
||||
cleaned = []
|
||||
cleaned = []
|
||||
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: None)
|
||||
monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: None)
|
||||
monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
|
||||
assert cleaned == ["work"]
|
||||
assert cleaned == ["work"]
|
||||
|
||||
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
|
||||
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: True)
|
||||
|
||||
native_host._cleanup("work")
|
||||
native_host._cleanup("work")
|
||||
|
||||
assert json.loads(registry_path.read_text()) == {}
|
||||
assert json.loads(registry_path.read_text()) == {}
|
||||
|
||||
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
|
||||
cleaned = []
|
||||
messages = iter([{"type": "bye"}])
|
||||
cleaned = []
|
||||
messages = iter([{"type": "bye"}])
|
||||
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
|
||||
assert cleaned == ["work"]
|
||||
assert cleaned == ["work"]
|
||||
|
||||
def test_stdin_reader_routes_response_messages(monkeypatch):
|
||||
response_queue = native_host.queue.Queue()
|
||||
native_host.PENDING["msg-1"] = response_queue
|
||||
messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None])
|
||||
response_queue = native_host.queue.Queue()
|
||||
native_host.PENDING["msg-1"] = response_queue
|
||||
messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None])
|
||||
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host, "_cleanup", lambda alias: None)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host, "_cleanup", lambda alias: None)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
|
||||
assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
|
||||
native_host.PENDING.clear()
|
||||
assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
|
||||
native_host.PENDING.clear()
|
||||
|
||||
def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
|
||||
calls = []
|
||||
pages = iter([
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}},
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}},
|
||||
])
|
||||
calls = []
|
||||
pages = iter([
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}},
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}},
|
||||
])
|
||||
|
||||
def fake_send(cmd):
|
||||
calls.append(cmd)
|
||||
return next(pages)
|
||||
def fake_send(cmd):
|
||||
calls.append(cmd)
|
||||
return next(pages)
|
||||
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 2)
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", fake_send)
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 2)
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", fake_send)
|
||||
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}})
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}})
|
||||
|
||||
assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3}
|
||||
assert [call["args"]["__page"] for call in calls] == [
|
||||
{"offset": 0, "limit": 2},
|
||||
{"offset": 2, "limit": 2},
|
||||
]
|
||||
assert all(call["args"]["foo"] == "bar" for call in calls)
|
||||
assert all(call["id"] != "orig" for call in calls)
|
||||
assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3}
|
||||
assert [call["args"]["__page"] for call in calls] == [
|
||||
{"offset": 0, "limit": 2},
|
||||
{"offset": 2, "limit": 2},
|
||||
]
|
||||
assert all(call["args"]["foo"] == "bar" for call in calls)
|
||||
assert all(call["id"] != "orig" for call in calls)
|
||||
|
||||
def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch):
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
|
||||
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
|
||||
assert result == {"id": "orig", "success": True, "data": {"value": 1}}
|
||||
assert result == {"id": "orig", "success": True, "data": {"value": 1}}
|
||||
|
||||
def test_handle_browser_command_pages_known_list_commands(monkeypatch):
|
||||
seen = []
|
||||
seen = []
|
||||
|
||||
monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []})
|
||||
monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []})
|
||||
|
||||
result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
|
||||
assert result == {"success": True, "data": []}
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
assert result == {"success": True, "data": []}
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
|
||||
def test_handle_browser_command_sends_non_pageable_directly(monkeypatch):
|
||||
seen = []
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"})
|
||||
seen = []
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"})
|
||||
|
||||
result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}})
|
||||
result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}})
|
||||
|
||||
assert result == {"success": True, "data": "ok"}
|
||||
assert seen[0]["command"] == "navigate.open"
|
||||
assert result == {"success": True, "data": "ok"}
|
||||
assert seen[0]["command"] == "navigate.open"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_exact_stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_read_exact_stream_full_read():
|
||||
"""Returns the exact bytes when stream delivers them in one shot."""
|
||||
import io
|
||||
stream = io.BytesIO(b"hello")
|
||||
assert native_protocol.read_exact_stream(stream, 5) == b"hello"
|
||||
"""Returns the exact bytes when stream delivers them in one shot."""
|
||||
import io
|
||||
stream = io.BytesIO(b"hello")
|
||||
assert native_protocol.read_exact_stream(stream, 5) == b"hello"
|
||||
|
||||
def test_read_exact_stream_partial_chunks():
|
||||
"""Accumulates multiple short chunks until n bytes are read."""
|
||||
import io
|
||||
"""Accumulates multiple short chunks until n bytes are read."""
|
||||
import io
|
||||
|
||||
class _ChunkyStream:
|
||||
def __init__(self, data, chunk_size):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._chunk_size = chunk_size
|
||||
class _ChunkyStream:
|
||||
def __init__(self, data, chunk_size):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._chunk_size = chunk_size
|
||||
|
||||
def read(self, n):
|
||||
end = min(self._pos + self._chunk_size, len(self._data))
|
||||
chunk = self._data[self._pos:end]
|
||||
self._pos = end
|
||||
return chunk
|
||||
def read(self, n):
|
||||
end = min(self._pos + self._chunk_size, len(self._data))
|
||||
chunk = self._data[self._pos:end]
|
||||
self._pos = end
|
||||
return chunk
|
||||
|
||||
stream = _ChunkyStream(b"abcdefgh", 3)
|
||||
assert native_protocol.read_exact_stream(stream, 8) == b"abcdefgh"
|
||||
stream = _ChunkyStream(b"abcdefgh", 3)
|
||||
assert native_protocol.read_exact_stream(stream, 8) == b"abcdefgh"
|
||||
|
||||
def test_read_exact_stream_eof_returns_none():
|
||||
"""Returns None if stream is exhausted before n bytes are delivered."""
|
||||
import io
|
||||
stream = io.BytesIO(b"ab") # only 2 bytes, asking for 4
|
||||
assert native_protocol.read_exact_stream(stream, 4) is None
|
||||
"""Returns None if stream is exhausted before n bytes are delivered."""
|
||||
import io
|
||||
stream = io.BytesIO(b"ab") # only 2 bytes, asking for 4
|
||||
assert native_protocol.read_exact_stream(stream, 4) is None
|
||||
|
||||
def test_read_exact_stream_immediate_eof():
|
||||
"""Returns None on an empty stream."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
assert native_protocol.read_exact_stream(stream, 1) is None
|
||||
"""Returns None on an empty stream."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
assert native_protocol.read_exact_stream(stream, 1) is None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_native_message / read_native_message round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_write_and_read_native_message_roundtrip():
|
||||
"""write_native_message followed by read_native_message recovers the original dict."""
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
msg = {"id": "abc", "command": "tabs.list", "args": {}}
|
||||
native_protocol.write_native_message(buf, msg)
|
||||
buf.seek(0)
|
||||
result = native_protocol.read_native_message(buf)
|
||||
assert result == msg
|
||||
"""write_native_message followed by read_native_message recovers the original dict."""
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
msg = {"id": "abc", "command": "tabs.list", "args": {}}
|
||||
native_protocol.write_native_message(buf, msg)
|
||||
buf.seek(0)
|
||||
result = native_protocol.read_native_message(buf)
|
||||
assert result == msg
|
||||
|
||||
def test_read_native_message_eof_at_length_prefix():
|
||||
"""Returns None when the stream is empty (no length prefix)."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
assert native_protocol.read_native_message(stream) is None
|
||||
"""Returns None when the stream is empty (no length prefix)."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
assert native_protocol.read_native_message(stream) is None
|
||||
|
||||
def test_read_native_message_eof_at_body():
|
||||
"""Returns None when the body is truncated after reading the length prefix."""
|
||||
import io
|
||||
import struct
|
||||
# Write a 10-byte length prefix but only 5 bytes of body
|
||||
buf = struct.pack("<I", 10) + b"hello"
|
||||
stream = io.BytesIO(buf)
|
||||
assert native_protocol.read_native_message(stream) is None
|
||||
"""Returns None when the body is truncated after reading the length prefix."""
|
||||
import io
|
||||
import struct
|
||||
# Write a 10-byte length prefix but only 5 bytes of body
|
||||
buf = struct.pack("<I", 10) + b"hello"
|
||||
stream = io.BytesIO(buf)
|
||||
assert native_protocol.read_native_message(stream) is None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# framing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_recv_exact_accumulates_data():
|
||||
"""framing.recv_exact receives exactly n bytes from a socket-like object."""
|
||||
class _FakeSock:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
def recv(self, n):
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
"""framing.recv_exact receives exactly n bytes from a socket-like object."""
|
||||
class _FakeSock:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
def recv(self, n):
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
sock = _FakeSock(b"0123456789")
|
||||
assert framing.recv_exact(sock, 5) == b"01234"
|
||||
assert framing.recv_exact(sock, 5) == b"56789"
|
||||
sock = _FakeSock(b"0123456789")
|
||||
assert framing.recv_exact(sock, 5) == b"01234"
|
||||
assert framing.recv_exact(sock, 5) == b"56789"
|
||||
|
||||
def test_recv_exact_eof_returns_none():
|
||||
class _EmptySock:
|
||||
def recv(self, n):
|
||||
return b""
|
||||
assert framing.recv_exact(_EmptySock(), 4, allow_eof=True) is None
|
||||
class _EmptySock:
|
||||
def recv(self, n):
|
||||
return b""
|
||||
assert framing.recv_exact(_EmptySock(), 4, allow_eof=True) is None
|
||||
|
||||
def test_send_all_and_recv_all():
|
||||
"""framing.send_frame frames data; framing.recv_frame strips it."""
|
||||
import socket
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
payload = b'{"command": "tabs.list"}'
|
||||
framing.send_frame(a, payload)
|
||||
received = framing.recv_frame(b, allow_eof=True)
|
||||
assert received == payload
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
"""framing.send_frame frames data; framing.recv_frame strips it."""
|
||||
import socket
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
payload = b'{"command": "tabs.list"}'
|
||||
framing.send_frame(a, payload)
|
||||
received = framing.recv_frame(b, allow_eof=True)
|
||||
assert received == payload
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_recv_all_truncated_body():
|
||||
"""_recv_all returns None when the body is shorter than the prefix promises."""
|
||||
import socket
|
||||
import struct
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
# Send a length of 100 but only 4 bytes of body
|
||||
a.sendall(struct.pack("<I", 100) + b"tiny")
|
||||
a.close()
|
||||
result = framing.recv_frame(b, allow_eof=True)
|
||||
assert result is None
|
||||
finally:
|
||||
b.close()
|
||||
"""_recv_all returns None when the body is shorter than the prefix promises."""
|
||||
import socket
|
||||
import struct
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
# Send a length of 100 but only 4 bytes of body
|
||||
a.sendall(struct.pack("<I", 100) + b"tiny")
|
||||
a.close()
|
||||
result = framing.recv_frame(b, allow_eof=True)
|
||||
assert result is None
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send_browser_command — timeout path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_send_browser_command_timeout(monkeypatch):
|
||||
"""_send_browser_command returns an error dict when the response queue times out."""
|
||||
import io
|
||||
"""_send_browser_command returns an error dict when the response queue times out."""
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
buf = io.BytesIO()
|
||||
|
||||
monkeypatch.setattr(native_host.sys, "stdout", SimpleNamespace(buffer=buf))
|
||||
# Do not put anything into the response queue → timeout after 0 s
|
||||
result = native_host._send_browser_command({"id": "t1", "command": "test", "args": {}}, timeout=0)
|
||||
monkeypatch.setattr(native_host.sys, "stdout", SimpleNamespace(buffer=buf))
|
||||
# Do not put anything into the response queue → timeout after 0 s
|
||||
result = native_host._send_browser_command({"id": "t1", "command": "test", "args": {}}, timeout=0)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "timeout" in result["error"]
|
||||
# Clean up PENDING
|
||||
native_host.PENDING.clear()
|
||||
assert result["success"] is False
|
||||
assert "timeout" in result["error"]
|
||||
# Clean up PENDING
|
||||
native_host.PENDING.clear()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _collect_paged_browser_command — error and loop-guard paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_collect_paged_browser_command_propagates_error(monkeypatch):
|
||||
"""If _send_browser_command returns success=False the error is propagated."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {"id": cmd["id"], "success": False, "error": "extension crash"},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "e1", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "extension crash" in result["error"]
|
||||
"""If _send_browser_command returns success=False the error is propagated."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {"id": cmd["id"], "success": False, "error": "extension crash"},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "e1", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "extension crash" in result["error"]
|
||||
|
||||
def test_collect_paged_browser_command_max_pages_guard(monkeypatch):
|
||||
"""If paging never ends, the loop guard kicks in and returns an error."""
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 1)
|
||||
"""A runaway extension (empty pages, advancing nextOffset) trips the guard."""
|
||||
monkeypatch.setattr(native_host, "MAX_PAGED_ITEMS", 5)
|
||||
|
||||
call_count = [0]
|
||||
call_count = [0]
|
||||
|
||||
def _infinite_pages(cmd):
|
||||
call_count[0] += 1
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [call_count[0]], "total": 9999, "nextOffset": call_count[0]},
|
||||
}
|
||||
def _infinite_empty_pages(cmd):
|
||||
# Empty items so the item cap never bites — only the page guard can stop this.
|
||||
call_count[0] += 1
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [], "total": 9999, "nextOffset": call_count[0]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _infinite_pages)
|
||||
result = native_host._collect_paged_browser_command({"id": "loop", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "paging loop exceeded" in result["error"]
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _infinite_empty_pages)
|
||||
result = native_host._collect_paged_browser_command({"id": "loop", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "paging loop exceeded" in result["error"]
|
||||
|
||||
def test_collect_paged_browser_command_stops_at_item_cap(monkeypatch):
|
||||
"""Paging stops once MAX_PAGED_ITEMS is reached, returning bounded data."""
|
||||
monkeypatch.setattr(native_host, "MAX_PAGED_ITEMS", 5)
|
||||
|
||||
offset = [0]
|
||||
|
||||
def _endless_items(cmd):
|
||||
offset[0] += 2
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [1, 2], "total": 100, "nextOffset": offset[0]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _endless_items)
|
||||
result = native_host._collect_paged_browser_command({"id": "cap", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is True
|
||||
assert len(result["data"]) >= 5 # stopped at/just past the cap, not unbounded
|
||||
|
||||
def test_collect_paged_browser_command_invalid_items(monkeypatch):
|
||||
"""If items is not a list the command returns an error dict."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": "not-a-list", "total": 1, "nextOffset": None},
|
||||
},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "bad", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "invalid paged response" in result["error"]
|
||||
"""If items is not a list the command returns an error dict."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": "not-a-list", "total": 1, "nextOffset": None},
|
||||
},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "bad", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "invalid paged response" in result["error"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_profile_alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_profile_alias_uses_hello_alias():
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": "brave-work"})
|
||||
assert alias == "brave-work"
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": "brave-work"})
|
||||
assert alias == "brave-work"
|
||||
|
||||
def test_resolve_profile_alias_no_hello_returns_uuid():
|
||||
alias = native_host._resolve_profile_alias(None)
|
||||
import uuid
|
||||
uuid.UUID(alias) # raises ValueError if not a valid UUID
|
||||
alias = native_host._resolve_profile_alias(None)
|
||||
import uuid
|
||||
uuid.UUID(alias) # raises ValueError if not a valid UUID
|
||||
|
||||
def test_resolve_profile_alias_default_alias_returns_uuid():
|
||||
from browser_cli.platform import DEFAULT_ALIAS
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": DEFAULT_ALIAS})
|
||||
import uuid
|
||||
uuid.UUID(alias)
|
||||
from browser_cli.platform import DEFAULT_ALIAS
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": DEFAULT_ALIAS})
|
||||
import uuid
|
||||
uuid.UUID(alias)
|
||||
|
||||
def test_resolve_profile_alias_non_hello_type_returns_uuid():
|
||||
alias = native_host._resolve_profile_alias({"type": "bye", "alias": "some"})
|
||||
import uuid
|
||||
uuid.UUID(alias)
|
||||
alias = native_host._resolve_profile_alias({"type": "bye", "alias": "some"})
|
||||
import uuid
|
||||
uuid.UUID(alias)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# asyncio Unix-socket server path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_async_recv_all_and_send_all_roundtrip():
|
||||
"""local_transport async framing mirrors the sync length-prefixed socket framing."""
|
||||
import asyncio
|
||||
"""local_transport async framing mirrors the sync length-prefixed socket framing."""
|
||||
import asyncio
|
||||
|
||||
async def run():
|
||||
async def handle(reader, writer):
|
||||
payload = await local_transport.async_recv_all(reader)
|
||||
await local_transport.async_send_all(writer, payload + b"-reply")
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
async def run():
|
||||
async def handle(reader, writer):
|
||||
payload = await local_transport.async_recv_all(reader)
|
||||
await local_transport.async_send_all(writer, payload + b"-reply")
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
||||
host, port = server.sockets[0].getsockname()
|
||||
async with server:
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
await local_transport.async_send_all(writer, b"hello")
|
||||
assert await local_transport.async_recv_all(reader) == b"hello-reply"
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
||||
host, port = server.sockets[0].getsockname()
|
||||
async with server:
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
await local_transport.async_send_all(writer, b"hello")
|
||||
assert await local_transport.async_recv_all(reader) == b"hello-reply"
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
asyncio.run(run())
|
||||
asyncio.run(run())
|
||||
|
||||
def test_async_socket_server_handles_cli_request(monkeypatch, tmp_path):
|
||||
"""Unix CLI socket server accepts requests concurrently via asyncio."""
|
||||
import asyncio
|
||||
import struct
|
||||
"""Unix CLI socket server accepts requests concurrently via asyncio."""
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
async def read_frame(reader):
|
||||
raw_len = await reader.readexactly(4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
return await reader.readexactly(msg_len)
|
||||
async def read_frame(reader):
|
||||
raw_len = await reader.readexactly(4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
return await reader.readexactly(msg_len)
|
||||
|
||||
async def run():
|
||||
sock_path = tmp_path / "browser.sock"
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
native_host,
|
||||
"_handle_browser_command",
|
||||
lambda cmd: seen.append(cmd) or {"id": cmd["id"], "success": True, "data": "ok"},
|
||||
)
|
||||
async def run():
|
||||
sock_path = tmp_path / "browser.sock"
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
native_host,
|
||||
"_handle_browser_command",
|
||||
lambda cmd: seen.append(cmd) or {"id": cmd["id"], "success": True, "data": "ok"},
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
local_server.async_socket_server(
|
||||
str(sock_path),
|
||||
native_host._handle_cli_payload,
|
||||
native_host._error_response,
|
||||
)
|
||||
)
|
||||
for _ in range(100):
|
||||
if sock_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
task = asyncio.create_task(
|
||||
local_server.async_socket_server(
|
||||
str(sock_path),
|
||||
native_host._handle_cli_payload,
|
||||
native_host._error_response,
|
||||
)
|
||||
)
|
||||
for _ in range(100):
|
||||
if sock_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
reader, writer = await asyncio.open_unix_connection(str(sock_path))
|
||||
await local_transport.async_send_all(writer, json.dumps({"command": "tabs.list", "args": {}}).encode())
|
||||
response = json.loads((await read_frame(reader)).decode())
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
reader, writer = await asyncio.open_unix_connection(str(sock_path))
|
||||
await local_transport.async_send_all(writer, json.dumps({"command": "tabs.list", "args": {}}).encode())
|
||||
response = json.loads((await read_frame(reader)).decode())
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["data"] == "ok"
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
assert "id" in seen[0]
|
||||
assert response["success"] is True
|
||||
assert response["data"] == "ok"
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
assert "id" in seen[0]
|
||||
|
||||
asyncio.run(run())
|
||||
asyncio.run(run())
|
||||
|
||||
+287
-138
@@ -10,179 +10,328 @@ from browser_cli import BrowserCLI
|
||||
from browser_cli.client import BrowserTarget
|
||||
from browser_cli.cli import main
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category
|
||||
from browser_cli.commands import command_policy_from_options
|
||||
|
||||
def test_extension_info_cli_renders_capabilities():
|
||||
with patch("browser_cli.send_command", return_value={"version": "1.2.3", "capabilities": ["extension.info"]}):
|
||||
result = CliRunner().invoke(main, ["extension", "info"])
|
||||
assert result.exit_code == 0
|
||||
assert "1.2.3" in result.output
|
||||
assert "extension.info" in result.output
|
||||
with patch("browser_cli.send_command", return_value={"version": "1.2.3", "capabilities": ["extension.info"]}):
|
||||
result = CliRunner().invoke(main, ["extension", "info"])
|
||||
assert result.exit_code == 0
|
||||
assert "1.2.3" in result.output
|
||||
assert "extension.info" in result.output
|
||||
|
||||
def test_script_runs_raw_commands(tmp_path: Path):
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"tabs.count": {"pattern": "example.com"}}]), encoding="utf-8")
|
||||
with patch("browser_cli.send_command", return_value={"count": 2}) as send_command:
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert "tabs.count" in result.output
|
||||
send_command.assert_called_once_with("tabs.count", {"pattern": "example.com"}, profile=None, remote=None, key=None)
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"tabs.count": {"pattern": "example.com"}}]), encoding="utf-8")
|
||||
with patch("browser_cli.send_command", return_value={"count": 2}) as send_command:
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert "tabs.count" in result.output
|
||||
send_command.assert_called_once_with("tabs.count", {"pattern": "example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_session_export_cli_prints_json():
|
||||
with patch("browser_cli.send_command", return_value={"name": "work", "session": {"tabs": ["https://example.com"]}}):
|
||||
result = CliRunner().invoke(main, ["session", "export", "work"])
|
||||
assert result.exit_code == 0
|
||||
assert '"name": "work"' in result.output
|
||||
with patch("browser_cli.send_command", return_value={"name": "work", "session": {"tabs": ["https://example.com"]}}):
|
||||
result = CliRunner().invoke(main, ["session", "export", "work"])
|
||||
assert result.exit_code == 0
|
||||
assert '"name": "work"' in result.output
|
||||
|
||||
def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
|
||||
calls = []
|
||||
calls = []
|
||||
|
||||
def sender(command, args=None, **kwargs):
|
||||
calls.append((command, args))
|
||||
if command == "tabs.list":
|
||||
return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}]
|
||||
return {}
|
||||
def sender(command, args=None, **kwargs):
|
||||
calls.append((command, args))
|
||||
if command == "tabs.list":
|
||||
return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}]
|
||||
return {}
|
||||
|
||||
BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True)
|
||||
assert calls == [
|
||||
("tabs.list", {}),
|
||||
("navigate.to", {"tabId": 7, "url": "https://example.com"}),
|
||||
]
|
||||
BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True)
|
||||
assert calls == [
|
||||
("tabs.list", {}),
|
||||
("navigate.to", {"tabId": 7, "url": "https://example.com"}),
|
||||
]
|
||||
|
||||
def _tree_sender(tabs, groups):
|
||||
def sender(command, args=None, **kwargs):
|
||||
if command == "tabs.list":
|
||||
return tabs
|
||||
if command == "group.list":
|
||||
return groups
|
||||
return []
|
||||
return sender
|
||||
def sender(command, args=None, **kwargs):
|
||||
if command == "tabs.list":
|
||||
return tabs
|
||||
if command == "group.list":
|
||||
return groups
|
||||
return []
|
||||
return sender
|
||||
|
||||
def test_tabs_tree_command_available():
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender([], [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Tabs" in result.output
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender([], [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Tabs" in result.output
|
||||
|
||||
def test_tabs_tree_handles_tabs_without_index_from_older_extension():
|
||||
tabs = [{
|
||||
"id": 7,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"muted": False,
|
||||
"title": "Example",
|
||||
"url": "https://example.com",
|
||||
"groupId": None,
|
||||
}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Example" in result.output
|
||||
tabs = [{
|
||||
"id": 7,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"muted": False,
|
||||
"title": "Example",
|
||||
"url": "https://example.com",
|
||||
"groupId": None,
|
||||
}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Example" in result.output
|
||||
|
||||
def test_tabs_tree_preserves_window_tab_order_and_truncates_long_lines():
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20},
|
||||
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None},
|
||||
]
|
||||
groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After")
|
||||
assert "https://example.com/before" not in output
|
||||
assert "https://example.com/grouped" not in output
|
||||
assert "https://example.com/" + "x" * 200 not in output
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20},
|
||||
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None},
|
||||
]
|
||||
groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After")
|
||||
assert "https://example.com/before" not in output
|
||||
assert "https://example.com/grouped" not in output
|
||||
assert "https://example.com/" + "x" * 200 not in output
|
||||
|
||||
def test_tabs_tree_adds_each_browser_node_only_once():
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"},
|
||||
]
|
||||
targets = [
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
BrowserTarget("personal", "personal", "/tmp/personal.sock"),
|
||||
]
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), \
|
||||
patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.count("work") == 1
|
||||
assert result.output.count("personal") == 1
|
||||
assert "One" in result.output
|
||||
assert "Two" in result.output
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"},
|
||||
]
|
||||
targets = [
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
BrowserTarget("personal", "personal", "/tmp/personal.sock"),
|
||||
]
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), \
|
||||
patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.count("work") == 1
|
||||
assert result.output.count("personal") == 1
|
||||
assert "One" in result.output
|
||||
assert "Two" in result.output
|
||||
|
||||
def test_tabs_tree_shows_tabs_inside_collapsed_browser_groups():
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20},
|
||||
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None},
|
||||
]
|
||||
groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Collapsed Group" in result.output
|
||||
assert "1 tab" in result.output
|
||||
assert "collapsed" in result.output
|
||||
assert "Hidden" in result.output
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20},
|
||||
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None},
|
||||
]
|
||||
groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Collapsed Group" in result.output
|
||||
assert "1 tab" in result.output
|
||||
assert "collapsed" in result.output
|
||||
assert "Hidden" in result.output
|
||||
|
||||
def test_tabs_tree_can_show_shortened_urls_on_request():
|
||||
tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree", "--urls"])
|
||||
assert result.exit_code == 0
|
||||
assert "https://example.com/" in result.output
|
||||
assert "https://example.com/" + "x" * 200 not in result.output
|
||||
assert "…" in result.output
|
||||
tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree", "--urls"])
|
||||
assert result.exit_code == 0
|
||||
assert "https://example.com/" in result.output
|
||||
assert "https://example.com/" + "x" * 200 not in result.output
|
||||
assert "…" in result.output
|
||||
|
||||
def test_doctor_command_reports_connection_failure_cleanly():
|
||||
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
|
||||
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
|
||||
result = CliRunner().invoke(main, ["doctor"])
|
||||
assert result.exit_code == 1
|
||||
assert "Connection" in result.output
|
||||
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
|
||||
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
|
||||
result = CliRunner().invoke(main, ["doctor"])
|
||||
assert result.exit_code == 1
|
||||
assert "Connection" in result.output
|
||||
|
||||
def test_serve_http_no_auth_rejected_on_public_host():
|
||||
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--no-auth"])
|
||||
assert result.exit_code != 0
|
||||
assert "--no-auth is only allowed on loopback" in result.output
|
||||
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--no-auth"])
|
||||
assert result.exit_code != 0
|
||||
assert "--no-auth is only allowed on loopback" in result.output
|
||||
|
||||
def test_serve_tcp_no_auth_rejected_on_public_host():
|
||||
result = CliRunner().invoke(main, ["serve", "--host", "0.0.0.0", "--no-auth"])
|
||||
assert result.exit_code != 0
|
||||
assert "--no-auth is only allowed on loopback" in result.output
|
||||
|
||||
def test_serve_tcp_no_auth_allowed_on_loopback():
|
||||
# Should pass the loopback guard and only fail later when trying to bind/serve.
|
||||
# We stop it before serve_forever by mocking _serve_async to a no-op.
|
||||
with patch("browser_cli.commands.serve._serve_async", return_value=None) as serve_async:
|
||||
result = CliRunner().invoke(main, ["serve", "--host", "127.0.0.1", "--no-auth"])
|
||||
assert result.exit_code == 0
|
||||
assert serve_async.called
|
||||
|
||||
def _serve_security_for(args):
|
||||
"""Invoke `serve` with the given args and return the ServeSecurity handed to _serve_async."""
|
||||
with patch("browser_cli.commands.serve._serve_async", return_value=None) as serve_async:
|
||||
result = CliRunner().invoke(main, ["serve", "--host", "127.0.0.1", "--no-auth", *args])
|
||||
assert result.exit_code == 0, result.output
|
||||
# _serve_async(host, port, profile, auth_keys_path, compress, security)
|
||||
return serve_async.call_args.args[5]
|
||||
|
||||
def _serve_policy_for(args):
|
||||
"""Convenience: the server-default CommandPolicy from a `serve` invocation."""
|
||||
return _serve_security_for(args).policy
|
||||
|
||||
def test_serve_tcp_defaults_to_safe_only_policy():
|
||||
policy = _serve_policy_for([])
|
||||
assert policy == CommandPolicy() # safe-only, nothing opened
|
||||
assert_command_allowed("tabs.list", policy)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("dom.eval", policy)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("navigate.open", policy)
|
||||
|
||||
def test_serve_tcp_allow_all_yields_unrestricted_policy():
|
||||
policy = _serve_policy_for(["--allow-all"])
|
||||
assert policy == CommandPolicy.unrestricted()
|
||||
assert_command_allowed("dom.eval", policy)
|
||||
assert_command_allowed("storage.get", policy)
|
||||
|
||||
def test_serve_tcp_allow_control_opens_only_control():
|
||||
policy = _serve_policy_for(["--allow-control"])
|
||||
assert_command_allowed("navigate.open", policy)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("dom.eval", policy) # dangerous still blocked
|
||||
|
||||
def test_serve_tcp_default_rate_limit_active():
|
||||
security = _serve_security_for([])
|
||||
assert security.rate_limiter is not None
|
||||
assert security.rate_limiter.rate == 100.0 # default
|
||||
|
||||
def test_serve_tcp_rate_limit_zero_disables():
|
||||
security = _serve_security_for(["--rate-limit", "0"])
|
||||
assert security.rate_limiter is None
|
||||
|
||||
def test_serve_tcp_per_key_policies_loaded_from_authorized_keys(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text("abc123 reader allow:read-page\ndef456 admin allow:all\nghi789 plain\n")
|
||||
security = _serve_security_for(["--authorized-keys", str(keys)])
|
||||
assert security.key_policies["abc123"] == CommandPolicy(allow_read_page=True)
|
||||
assert security.key_policies["def456"] == CommandPolicy.unrestricted()
|
||||
assert "ghi789" not in security.key_policies # falls back to server default
|
||||
assert security.key_names["def456"] == "admin"
|
||||
|
||||
def test_auth_trust_writes_inline_policy_token(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
result = CliRunner().invoke(main, [
|
||||
"auth", "trust", pub, "--name", "ci bot", "--file", str(keys),
|
||||
"--allow-read-page", "--allow-control",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
line = keys.read_text().strip()
|
||||
assert line == f"{pub} ci bot allow:read-page,control"
|
||||
|
||||
def test_auth_trust_without_allow_flags_writes_no_token(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
result = CliRunner().invoke(main, ["auth", "trust", pub, "--name", "plain", "--file", str(keys)])
|
||||
assert result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} plain"
|
||||
|
||||
def test_auth_keys_local_shows_policy_column(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} reader allow:read-page\n{'b' * 64} admin allow:all\n{'c' * 64} plain\n")
|
||||
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
|
||||
assert result.exit_code == 0
|
||||
assert "Policy" in result.output
|
||||
assert "read-page" in result.output
|
||||
assert "all" in result.output
|
||||
assert "server default" in result.output
|
||||
|
||||
def test_auth_keys_remote_unreachable_clean_error():
|
||||
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
|
||||
with patch("browser_cli.client.send_command", side_effect=BrowserNotConnected("Cannot connect to remote browser at x.")):
|
||||
result = CliRunner().invoke(main, ["--remote", "x.example:8765", "auth", "keys"])
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, SystemExit) # handled, not a raw exception
|
||||
assert "Error:" in result.output
|
||||
assert "Cannot connect" in result.output
|
||||
|
||||
def test_auth_trust_remote_unreachable_clean_error():
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
|
||||
with patch("browser_cli.client.send_command", side_effect=BrowserNotConnected("Cannot connect to remote browser at x.")):
|
||||
result = CliRunner().invoke(main, ["--remote", "x.example:8765", "auth", "trust", "a" * 64])
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, SystemExit)
|
||||
assert "Error:" in result.output
|
||||
|
||||
def test_serve_http_token_check_is_constant_time():
|
||||
"""The bearer-token comparison uses secrets.compare_digest, not ==."""
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
|
||||
handler = _Handler.__new__(_Handler)
|
||||
handler.token = "s3cret-token"
|
||||
handler.headers = {"Authorization": "Bearer s3cret-token"}
|
||||
assert handler._authorized() is True
|
||||
handler.headers = {"Authorization": "Bearer wrong"}
|
||||
assert handler._authorized() is False
|
||||
handler.headers = {"X-Browser-CLI-Token": "s3cret-token"}
|
||||
assert handler._authorized() is True
|
||||
handler.headers = {"X-Browser-CLI-Token": "nope"}
|
||||
assert handler._authorized() is False
|
||||
handler.headers = {}
|
||||
assert handler._authorized() is False
|
||||
# No token configured → open.
|
||||
handler.token = None
|
||||
assert handler._authorized() is True
|
||||
|
||||
def test_serve_http_uses_compare_digest():
|
||||
import inspect
|
||||
from browser_cli.commands import serve_http
|
||||
|
||||
src = inspect.getsource(serve_http._Handler._authorized)
|
||||
assert "compare_digest" in src
|
||||
assert "== f\"Bearer" not in src
|
||||
|
||||
def test_command_policy_allow_all_grants_everything():
|
||||
policy = command_policy_from_options(
|
||||
allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True
|
||||
)
|
||||
assert policy == CommandPolicy.unrestricted()
|
||||
assert_command_allowed("dom.eval", policy)
|
||||
assert_command_allowed("storage.get", policy)
|
||||
|
||||
def test_raw_command_blocks_dangerous_by_default():
|
||||
result = CliRunner().invoke(main, ["command", "dom.eval", '{"code":"document.title"}'])
|
||||
assert result.exit_code != 0
|
||||
assert "blocked by default" in result.output
|
||||
result = CliRunner().invoke(main, ["command", "dom.eval", '{"code":"document.title"}'])
|
||||
assert result.exit_code != 0
|
||||
assert "blocked by default" in result.output
|
||||
|
||||
def test_raw_command_allows_dangerous_with_explicit_flag():
|
||||
with patch("browser_cli.send_command", return_value="Example") as send_command:
|
||||
result = CliRunner().invoke(main, ["command", "--allow-dangerous", "dom.eval", '{"code":"document.title"}'])
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with("dom.eval", {"code": "document.title"}, profile=None, remote=None, key=None)
|
||||
with patch("browser_cli.send_command", return_value="Example") as send_command:
|
||||
result = CliRunner().invoke(main, ["command", "--allow-dangerous", "dom.eval", '{"code":"document.title"}'])
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with("dom.eval", {"code": "document.title"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_script_blocks_control_without_explicit_flag(tmp_path: Path):
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert "blocked by default" in result.output
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert "blocked by default" in result.output
|
||||
|
||||
def test_script_allows_control_with_explicit_flag(tmp_path: Path):
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
|
||||
with patch("browser_cli.send_command", return_value={}) as send_command:
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json", "--allow-control"])
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with("navigate.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
|
||||
with patch("browser_cli.send_command", return_value={}) as send_command:
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json", "--allow-control"])
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with("navigate.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_command_policy_categories_and_flags():
|
||||
assert command_category("tabs.list") == "safe"
|
||||
assert command_category("extract.text") == "read-page"
|
||||
assert command_category("dom.click") == "control"
|
||||
assert command_category("storage.get") == "dangerous"
|
||||
assert_command_allowed("tabs.list", CommandPolicy())
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("extract.text", CommandPolicy())
|
||||
assert_command_allowed("extract.text", CommandPolicy(allow_read_page=True))
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("storage.get", CommandPolicy(allow_read_page=True, allow_control=True))
|
||||
assert_command_allowed("storage.get", CommandPolicy(allow_dangerous=True))
|
||||
assert command_category("tabs.list") == "safe"
|
||||
assert command_category("extract.text") == "read-page"
|
||||
assert command_category("dom.click") == "control"
|
||||
assert command_category("storage.get") == "dangerous"
|
||||
assert_command_allowed("tabs.list", CommandPolicy())
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("extract.text", CommandPolicy())
|
||||
assert_command_allowed("extract.text", CommandPolicy(allow_read_page=True))
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("storage.get", CommandPolicy(allow_read_page=True, allow_control=True))
|
||||
assert_command_allowed("storage.get", CommandPolicy(allow_dangerous=True))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Unit tests for the remote connection pool (browser_cli.remote.pool)."""
|
||||
import socket
|
||||
|
||||
from browser_cli.remote import pool
|
||||
|
||||
def _socketpair():
|
||||
a, b = socket.socketpair()
|
||||
return a, b
|
||||
|
||||
def test_checkin_then_checkout_returns_same_connection():
|
||||
pool.close_all()
|
||||
a, b = _socketpair()
|
||||
conn = pool.PooledConnection(a, b"secret")
|
||||
pool.checkin("host:1", conn)
|
||||
assert pool.checkout("host:1") is conn
|
||||
assert pool.checkout("host:1") is None # only one was pooled
|
||||
b.close()
|
||||
pool.close_all()
|
||||
|
||||
def test_checkout_drops_stale_connection(monkeypatch):
|
||||
pool.close_all()
|
||||
a, b = _socketpair()
|
||||
conn = pool.PooledConnection(a, b"secret")
|
||||
pool.checkin("host:2", conn)
|
||||
# Make the pooled connection look older than the idle bound.
|
||||
conn.last_used -= (pool._MAX_IDLE_SECONDS + 1)
|
||||
assert pool.checkout("host:2") is None # stale → dropped, not returned
|
||||
b.close()
|
||||
pool.close_all()
|
||||
|
||||
def test_checkin_caps_pool_size():
|
||||
pool.close_all()
|
||||
sockets = []
|
||||
for i in range(pool._MAX_PER_ENDPOINT + 3):
|
||||
a, b = _socketpair()
|
||||
sockets.append(b)
|
||||
pool.checkin("host:3", pool.PooledConnection(a, b"secret"))
|
||||
drained = 0
|
||||
while pool.checkout("host:3") is not None:
|
||||
drained += 1
|
||||
assert drained == pool._MAX_PER_ENDPOINT
|
||||
for b in sockets:
|
||||
b.close()
|
||||
pool.close_all()
|
||||
|
||||
def test_session_inner_message_strips_auth_fields():
|
||||
msg = {
|
||||
"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1",
|
||||
"pubkey": "x", "sig": "y", "pq_kex": {}, "encrypted": {}, "accept_encoding": {"x": 1},
|
||||
}
|
||||
inner = pool.session_inner_message(msg)
|
||||
assert inner == {"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1", "accept_encoding": {"x": 1}}
|
||||
@@ -16,198 +16,262 @@ import threading
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth import (
|
||||
generate_keypair,
|
||||
load_private_key,
|
||||
pq_decrypt,
|
||||
pq_encrypt,
|
||||
pq_kex_client_encapsulate,
|
||||
pq_kex_server_decapsulate,
|
||||
pq_kex_server_keypair,
|
||||
sign,
|
||||
generate_keypair,
|
||||
load_private_key,
|
||||
pq_decrypt,
|
||||
pq_encrypt,
|
||||
pq_kex_client_encapsulate,
|
||||
pq_kex_server_decapsulate,
|
||||
pq_kex_server_keypair,
|
||||
sign,
|
||||
)
|
||||
from browser_cli.client import BrowserNotConnected, send_command
|
||||
from browser_cli.commands.serve import _handle_client
|
||||
|
||||
def _send_framed(sock: socket.socket, msg: dict) -> None:
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock.sendall(struct.pack("<I", len(payload)) + payload)
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock.sendall(struct.pack("<I", len(payload)) + payload)
|
||||
|
||||
def _recv_framed(sock: socket.socket) -> dict:
|
||||
raw_len = b""
|
||||
while len(raw_len) < 4:
|
||||
chunk = sock.recv(4 - len(raw_len))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw_len += chunk
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
data = b""
|
||||
while len(data) < msg_len:
|
||||
chunk = sock.recv(msg_len - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
raw_len = b""
|
||||
while len(raw_len) < 4:
|
||||
chunk = sock.recv(4 - len(raw_len))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw_len += chunk
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
data = b""
|
||||
while len(data) < msg_len:
|
||||
chunk = sock.recv(msg_len - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_material(tmp_path):
|
||||
pem, pub = generate_keypair()
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
auth_path = tmp_path / "authorized_keys"
|
||||
auth_path.write_text(pub + "\n", encoding="utf-8")
|
||||
return key_path, auth_path, load_private_key(key_path), pub
|
||||
pem, pub = generate_keypair()
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
auth_path = tmp_path / "authorized_keys"
|
||||
auth_path.write_text(pub + "\n", encoding="utf-8")
|
||||
return key_path, auth_path, load_private_key(key_path), pub
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_browser(monkeypatch):
|
||||
def _raise_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
def _raise_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _raise_no_browser)
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _raise_no_browser)
|
||||
|
||||
def _connect(auth_keys_path):
|
||||
client, server = socket.socketpair()
|
||||
thread = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
challenge = _recv_framed(client)
|
||||
return client, thread, challenge
|
||||
client, server = socket.socketpair()
|
||||
thread = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
challenge = _recv_framed(client)
|
||||
return client, thread, challenge
|
||||
|
||||
def _pq_auth_message(priv, pub: str, nonce_hex: str, command_msg: dict, challenge: dict, *, encrypted: bool) -> tuple[dict, bytes]:
|
||||
if "pq_kex" not in challenge:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
if "pq_kex" not in challenge:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
|
||||
ciphertext_hex, shared_secret = pq_kex_client_encapsulate(challenge["pq_kex"]["public_key"])
|
||||
clean_msg = {
|
||||
**command_msg,
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": ciphertext_hex},
|
||||
}
|
||||
sig = sign(priv, bytes.fromhex(nonce_hex), clean_msg, shared_secret).hex()
|
||||
if not encrypted:
|
||||
return {**clean_msg, "pubkey": pub, "sig": sig}, shared_secret
|
||||
ciphertext_hex, shared_secret = pq_kex_client_encapsulate(challenge["pq_kex"]["public_key"])
|
||||
clean_msg = {
|
||||
**command_msg,
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": ciphertext_hex},
|
||||
}
|
||||
sig = sign(priv, bytes.fromhex(nonce_hex), clean_msg, shared_secret).hex()
|
||||
if not encrypted:
|
||||
return {**clean_msg, "pubkey": pub, "sig": sig}, shared_secret
|
||||
|
||||
envelope = {
|
||||
"id": clean_msg["id"],
|
||||
"user_agent": clean_msg["user_agent"],
|
||||
"pubkey": pub,
|
||||
"sig": sig,
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": pq_encrypt(shared_secret, "request", json.dumps(clean_msg).encode("utf-8")),
|
||||
}
|
||||
return envelope, shared_secret
|
||||
envelope = {
|
||||
"id": clean_msg["id"],
|
||||
"user_agent": clean_msg["user_agent"],
|
||||
"pubkey": pub,
|
||||
"sig": sig,
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": pq_encrypt(shared_secret, "request", json.dumps(clean_msg).encode("utf-8")),
|
||||
}
|
||||
return envelope, shared_secret
|
||||
|
||||
def _assert_browser_not_connected(resp: dict) -> None:
|
||||
assert resp.get("success") is False
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
assert resp.get("success") is False
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
|
||||
def test_real_mlkem_primitive_roundtrip():
|
||||
keypair = pq_kex_server_keypair()
|
||||
if keypair is None:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
private_key, public_key = keypair
|
||||
keypair = pq_kex_server_keypair()
|
||||
if keypair is None:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
private_key, public_key = keypair
|
||||
|
||||
ciphertext_hex, client_secret = pq_kex_client_encapsulate(public_key.hex())
|
||||
server_secret = pq_kex_server_decapsulate(private_key, ciphertext_hex)
|
||||
ciphertext_hex, client_secret = pq_kex_client_encapsulate(public_key.hex())
|
||||
server_secret = pq_kex_server_decapsulate(private_key, ciphertext_hex)
|
||||
|
||||
assert server_secret == client_secret
|
||||
assert server_secret == client_secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("client_version", "encrypted", "expect_encrypted_response"),
|
||||
[
|
||||
("0.9.3", False, False), # legacy client stays compatible
|
||||
("0.9.5", True, True), # current client must use encrypted transport
|
||||
],
|
||||
("client_version", "encrypted", "expect_encrypted_response"),
|
||||
[
|
||||
("0.9.3", False, False), # legacy client stays compatible
|
||||
("0.9.5", True, True), # current client must use encrypted transport
|
||||
],
|
||||
)
|
||||
def test_remote_protocol_version_matrix(auth_material, client_version, encrypted, expect_encrypted_response):
|
||||
selected_version = os.environ.get("BROWSER_CLI_COMPAT_CLIENT_VERSION")
|
||||
if selected_version and selected_version != client_version:
|
||||
pytest.skip(f"compat matrix selected {selected_version}")
|
||||
selected_version = os.environ.get("BROWSER_CLI_COMPAT_CLIENT_VERSION")
|
||||
if selected_version and selected_version != client_version:
|
||||
pytest.skip(f"compat matrix selected {selected_version}")
|
||||
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
|
||||
msg = {
|
||||
"id": f"tabs-{client_version}",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": f"browser-cli/{client_version}",
|
||||
}
|
||||
wire_msg, shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=encrypted)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
msg = {
|
||||
"id": f"tabs-{client_version}",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": f"browser-cli/{client_version}",
|
||||
}
|
||||
wire_msg, shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=encrypted)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
|
||||
if expect_encrypted_response:
|
||||
assert set(resp) == {"encrypted"}
|
||||
resp = json.loads(pq_decrypt(shared_secret, "response", resp["encrypted"]))
|
||||
else:
|
||||
assert "encrypted" not in resp
|
||||
if expect_encrypted_response:
|
||||
assert set(resp) == {"encrypted"}
|
||||
resp = json.loads(pq_decrypt(shared_secret, "response", resp["encrypted"]))
|
||||
else:
|
||||
assert "encrypted" not in resp
|
||||
|
||||
_assert_browser_not_connected(resp)
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
_assert_browser_not_connected(resp)
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_current_client_plaintext_transport_is_rejected(auth_material):
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
|
||||
msg = {
|
||||
"id": "new-plain",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
}
|
||||
wire_msg, _shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=False)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
msg = {
|
||||
"id": "new-plain",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
}
|
||||
wire_msg, _shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=False)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp.get("success") is False
|
||||
assert "encrypted transport" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
assert resp.get("success") is False
|
||||
assert "encrypted transport" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_send_command_uses_encrypted_remote_transport(auth_material):
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
def test_send_command_uses_encrypted_remote_transport(auth_material, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "remotes.json"
|
||||
)
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "remotes.json"
|
||||
)
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: None)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
|
||||
thread.join(timeout=2)
|
||||
assert "not using a post-quantum key exchange" in stderr.getvalue()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch):
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: None)
|
||||
def _run_pool_server(server, auth_path, connections):
|
||||
server.settimeout(3)
|
||||
while True:
|
||||
try:
|
||||
conn, addr = server.accept()
|
||||
except OSError:
|
||||
return
|
||||
connections.append(conn)
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, None, auth_path), daemon=True).start()
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
def test_send_command_reuses_pooled_connection(auth_material):
|
||||
"""Two sequential commands to one endpoint share a single authenticated connection."""
|
||||
from browser_cli.remote import pool
|
||||
pool.close_all()
|
||||
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(2)
|
||||
host, port = server.getsockname()
|
||||
connections = []
|
||||
threading.Thread(target=_run_pool_server, args=(server, auth_path, connections), daemon=True).start()
|
||||
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
endpoint = f"{host}:{port}"
|
||||
try:
|
||||
for _ in range(2):
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=endpoint, profile="default", key=key_path)
|
||||
assert len(connections) == 1 # the second command reused the first connection
|
||||
finally:
|
||||
pool.close_all()
|
||||
server.close()
|
||||
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
def test_send_command_opens_new_connection_when_pool_empty(auth_material):
|
||||
"""With no pooled connection to reuse, each command opens its own."""
|
||||
from browser_cli.remote import pool
|
||||
|
||||
assert "not using a post-quantum key exchange" in stderr.getvalue()
|
||||
thread.join(timeout=2)
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(2)
|
||||
host, port = server.getsockname()
|
||||
connections = []
|
||||
threading.Thread(target=_run_pool_server, args=(server, auth_path, connections), daemon=True).start()
|
||||
|
||||
endpoint = f"{host}:{port}"
|
||||
try:
|
||||
for _ in range(2):
|
||||
pool.close_all() # drop the pool before each call → no reuse
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=endpoint, profile="default", key=key_path)
|
||||
assert len(connections) == 2 # each command handshaked its own connection
|
||||
finally:
|
||||
pool.close_all()
|
||||
server.close()
|
||||
|
||||
+544
-422
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
"""Unit tests for serve-side security: per-key policy loading, rate limiting, context."""
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth.keys import (
|
||||
_parse_authorized_line,
|
||||
format_authorized_line,
|
||||
load_authorized_keys_with_names,
|
||||
load_authorized_keys_with_policies,
|
||||
)
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.serve.security import (
|
||||
RateLimiter,
|
||||
ServeSecurity,
|
||||
key_policies_from_authorized_keys,
|
||||
policy_from_categories,
|
||||
)
|
||||
|
||||
# ── policy_from_categories ───────────────────────────────────────────────────────
|
||||
|
||||
def test_policy_from_categories_all_is_unrestricted():
|
||||
assert policy_from_categories(["all"]) == CommandPolicy.unrestricted()
|
||||
|
||||
def test_policy_from_categories_subset():
|
||||
policy = policy_from_categories(["read-page", "control"])
|
||||
assert policy == CommandPolicy(allow_read_page=True, allow_control=True)
|
||||
assert policy.allow_dangerous is False
|
||||
|
||||
def test_policy_from_categories_safe_and_empty_are_noops():
|
||||
assert policy_from_categories(["safe"]) == CommandPolicy()
|
||||
assert policy_from_categories([]) == CommandPolicy()
|
||||
|
||||
def test_policy_from_categories_rejects_unknown():
|
||||
with pytest.raises(ValueError, match="unknown command category"):
|
||||
policy_from_categories(["bogus"])
|
||||
|
||||
def test_policy_from_categories_keys():
|
||||
assert policy_from_categories(["keys"]) == CommandPolicy(allow_keys=True)
|
||||
|
||||
# ── keys category gating ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_key_commands_are_keys_category():
|
||||
from browser_cli.command_security import command_category
|
||||
assert command_category("browser-cli.auth.keys") == "keys"
|
||||
assert command_category("browser-cli.auth.trust") == "keys"
|
||||
assert command_category("browser-cli.targets") == "safe" # discovery stays open
|
||||
|
||||
def test_key_commands_blocked_without_allow_keys():
|
||||
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust"):
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed(cmd, CommandPolicy()) # safe-only default
|
||||
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
|
||||
assert_command_allowed(cmd, CommandPolicy.unrestricted()) # all includes keys
|
||||
|
||||
def test_full_control_still_cannot_manage_keys():
|
||||
"""A key with control+dangerous (but not keys) cannot list/trust keys."""
|
||||
policy = CommandPolicy(allow_read_page=True, allow_control=True, allow_dangerous=True)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("browser-cli.auth.trust", policy)
|
||||
|
||||
# ── authorized_keys line parsing ─────────────────────────────────────────────────
|
||||
|
||||
def test_parse_line_pubkey_only():
|
||||
assert _parse_authorized_line("abc123") == ("abc123", "", None)
|
||||
|
||||
def test_parse_line_name_with_spaces_no_policy():
|
||||
# A multi-word name (e.g. "YubiKey 5C NFC FIPS") must stay intact, policy None.
|
||||
assert _parse_authorized_line("abc YubiKey 5C NFC FIPS") == ("abc", "YubiKey 5C NFC FIPS", None)
|
||||
|
||||
def test_parse_line_name_with_spaces_and_policy():
|
||||
pub, name, cats = _parse_authorized_line("abc YubiKey 5C NFC FIPS allow:read-page,control")
|
||||
assert pub == "abc"
|
||||
assert name == "YubiKey 5C NFC FIPS" # allow: token stripped out of the name
|
||||
assert cats == ["read-page", "control"]
|
||||
|
||||
def test_parse_line_empty_allow_is_safe():
|
||||
assert _parse_authorized_line("abc name allow:") == ("abc", "name", [])
|
||||
|
||||
def test_parse_line_skips_comments_and_blanks():
|
||||
assert _parse_authorized_line("# comment") is None
|
||||
assert _parse_authorized_line(" ") is None
|
||||
|
||||
def test_format_authorized_line_roundtrips():
|
||||
line = format_authorized_line("abc", "my laptop", ["read-page", "control"])
|
||||
assert line == "abc my laptop allow:read-page,control"
|
||||
assert _parse_authorized_line(line) == ("abc", "my laptop", ["read-page", "control"])
|
||||
# No categories → no allow token.
|
||||
assert format_authorized_line("abc", "laptop") == "abc laptop"
|
||||
|
||||
# ── key_policies_from_authorized_keys ────────────────────────────────────────────
|
||||
|
||||
def test_key_policies_from_authorized_keys(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(
|
||||
"AABBCC laptop allow:all\n"
|
||||
"ddee01 ci-bot allow:read-page,control\n"
|
||||
"112233 readonly\n" # no allow token → no override entry
|
||||
)
|
||||
policies = key_policies_from_authorized_keys(path)
|
||||
assert policies["aabbcc"] == CommandPolicy.unrestricted() # normalised to lowercase
|
||||
assert policies["ddee01"] == CommandPolicy(allow_read_page=True, allow_control=True)
|
||||
assert "112233" not in policies # falls back to server default
|
||||
|
||||
def test_key_policies_none_returns_empty():
|
||||
assert key_policies_from_authorized_keys(None) == {}
|
||||
|
||||
def test_key_policies_rejects_unknown_category(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("abc name allow:bogus\n")
|
||||
with pytest.raises(ValueError, match="unknown command category"):
|
||||
key_policies_from_authorized_keys(path)
|
||||
|
||||
def test_load_with_names_ignores_allow_token(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("abc my laptop allow:control\n")
|
||||
assert load_authorized_keys_with_names(path) == [("abc", "my laptop")]
|
||||
assert load_authorized_keys_with_policies(path) == [("abc", "my laptop", ["control"])]
|
||||
|
||||
# ── RateLimiter ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_rate_limiter_zero_rate_never_limits():
|
||||
limiter = RateLimiter(rate=0)
|
||||
assert all(limiter.allow("k") for _ in range(1000))
|
||||
|
||||
def test_rate_limiter_burst_then_block():
|
||||
limiter = RateLimiter(rate=0.0001, burst=3)
|
||||
assert limiter.allow("k") is True
|
||||
assert limiter.allow("k") is True
|
||||
assert limiter.allow("k") is True
|
||||
assert limiter.allow("k") is False # bucket drained, refill negligible
|
||||
|
||||
def test_rate_limiter_is_per_key():
|
||||
limiter = RateLimiter(rate=0.0001, burst=1)
|
||||
assert limiter.allow("a") is True
|
||||
assert limiter.allow("b") is True # different key has its own bucket
|
||||
assert limiter.allow("a") is False
|
||||
assert limiter.allow("b") is False
|
||||
|
||||
# ── ServeSecurity ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_effective_policy_prefers_per_key_override():
|
||||
sec = ServeSecurity(
|
||||
policy=CommandPolicy.unrestricted(),
|
||||
key_policies={"abc": CommandPolicy()},
|
||||
)
|
||||
assert sec.effective_policy("abc") == CommandPolicy() # override
|
||||
assert sec.effective_policy("other") == CommandPolicy.unrestricted() # default
|
||||
assert sec.effective_policy(None) == CommandPolicy.unrestricted()
|
||||
# And the override actually gates a dangerous command:
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("dom.eval", sec.effective_policy("abc"))
|
||||
|
||||
def test_label_for_renders_name_and_short_pubkey():
|
||||
sec = ServeSecurity(key_names={"ab12cd34ef": "laptop"})
|
||||
assert sec.label_for("ab12cd34ef") == "laptop ab12cd34…"
|
||||
assert sec.label_for("ffeeddccbb") == "ffeeddcc…" # unknown key → short pubkey only
|
||||
assert sec.label_for(None) is None
|
||||
|
||||
def test_serve_security_defaults_are_safe():
|
||||
sec = ServeSecurity()
|
||||
assert sec.key_policies == {}
|
||||
assert sec.key_names == {}
|
||||
assert sec.rate_limiter is None
|
||||
Reference in New Issue
Block a user