fix: restore Python 3.10/3.11 support and report MCP version
Testing / test (3.10) (push) Successful in 48s
Testing / test (3.12) (push) Successful in 47s
Testing / test (3.14) (push) Successful in 1m7s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 6m59s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 6m47s
Testing / test (3.15) (push) Successful in 6m20s
Testing / test (3.10) (push) Successful in 48s
Testing / test (3.12) (push) Successful in 47s
Testing / test (3.14) (push) Successful in 1m7s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 6m59s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 6m47s
Testing / test (3.15) (push) Successful in 6m20s
- Drop backslash from f-string expression in markdown/html.py - That syntax needs 3.12+ (PEP 701) and broke import on 3.10/3.11 - Add Python matrix 3.10/3.12/3.14/3.15 to the Gitea testing workflow - Pass project_version() to MCPServer; serverInfo.version was empty - Cover the reported version with an in-memory MCP client test - Swap deprecated click.get_text_stream for sys.stdin.isatty() - Add explicit encoding to native host shim and manifest writes - Add explicit encoding to the registry lock file open - Add explicit encoding to every read_text/write_text call in tests - Bump version to 0.16.10 in pyproject.toml and extension/manifest.json - Move .python-version to 3.15 and refresh uv.lock Extension code is unchanged; only the manifest version string moved. Verified with 778 tests on 3.10 through 3.15, plus a strict run using -X warn_default_encoding with EncodingWarning and DeprecationWarning as errors.
This commit is contained in:
+1
-1
@@ -163,7 +163,7 @@ class TestAuthorizedKeys:
|
||||
|
||||
def test_ignores_comment_lines(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("# this is a comment\n")
|
||||
path.write_text("# this is a comment\n", encoding="utf-8")
|
||||
assert load_authorized_keys(path) == []
|
||||
|
||||
def test_returns_empty_for_missing_file(self, tmp_path):
|
||||
|
||||
+1
-1
@@ -208,7 +208,7 @@ def test_write_native_host_exe_unix(tmp_path):
|
||||
write_native_host_exe(host)
|
||||
|
||||
assert host.exists()
|
||||
content = host.read_text()
|
||||
content = host.read_text(encoding="utf-8")
|
||||
assert content.startswith(f"#!{sys.executable}")
|
||||
assert "from browser_cli.native.host import main" in content
|
||||
assert host.stat().st_mode & 0o111 # executable bit set
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_resolve_socket_uses_only_active_registry_entry(monkeypatch, tmp_path):
|
||||
socket_path = tmp_path / "browser.sock"
|
||||
listener = _listening_unix_socket(socket_path)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}))
|
||||
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_pat
|
||||
first_listener = _listening_unix_socket(first_socket)
|
||||
second_listener = _listening_unix_socket(second_socket)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"uuid-1": str(first_socket), "uuid-2": str(second_socket)}))
|
||||
registry_path.write_text(json.dumps({"uuid-1": str(first_socket), "uuid-2": str(second_socket)}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
||||
|
||||
@@ -87,7 +87,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
||||
stale_listener = _listening_unix_socket(stale_socket)
|
||||
stale_listener.close()
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}))
|
||||
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
||||
|
||||
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
|
||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr("browser_cli.client.targets.is_windows", lambda: True)
|
||||
@@ -174,7 +174,7 @@ def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monke
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
socket_path = tmp_path / "work.sock"
|
||||
socket_path.write_text("")
|
||||
socket_path.write_text("", encoding="utf-8")
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": str(socket_path)}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
||||
|
||||
@@ -8,14 +8,14 @@ def read_built_background() -> str:
|
||||
background_path = ROOT / "extension" / "background.js"
|
||||
if not background_path.exists():
|
||||
pytest.skip("extension/background.js is a generated build artifact; run npm run build:extension")
|
||||
return background_path.read_text()
|
||||
return background_path.read_text(encoding="utf-8")
|
||||
|
||||
def test_extension_retries_error_page_script_injection_before_failing():
|
||||
# core.ts was split into a core/ subfolder during the structure refactor:
|
||||
# the URL/error classifiers live in core/errors.ts and the executeScript
|
||||
# retry wrapper (which calls isTransientScriptError) lives in core/scripting.ts.
|
||||
errors = (ROOT / "extension" / "src" / "core" / "errors.ts").read_text()
|
||||
scripting = (ROOT / "extension" / "src" / "core" / "scripting.ts").read_text()
|
||||
errors = (ROOT / "extension" / "src" / "core" / "errors.ts").read_text(encoding="utf-8")
|
||||
scripting = (ROOT / "extension" / "src" / "core" / "scripting.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "isBrowserErrorUrl" in errors
|
||||
assert "isErrorPageScriptError" in errors
|
||||
@@ -27,7 +27,7 @@ def test_extension_retries_error_page_script_injection_before_failing():
|
||||
assert "isTransientScriptError(e)" in scripting
|
||||
|
||||
def test_read_only_dom_commands_have_error_page_fallbacks():
|
||||
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text()
|
||||
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "fallbackForErrorPageDomOp" in dom
|
||||
assert 'case "domExists":' in dom
|
||||
@@ -40,8 +40,8 @@ def test_read_only_dom_commands_have_error_page_fallbacks():
|
||||
def test_navigation_and_tabs_report_browser_error_pages():
|
||||
# tabs.watch_url (which reports tab error pages) moved into the read-only
|
||||
# TabsQueryCommands class in tabs-query.ts during the structure refactor.
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs-query.ts").read_text()
|
||||
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs-query.ts").read_text(encoding="utf-8")
|
||||
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "lastUrl" in tabs
|
||||
assert "lastStatus" in tabs
|
||||
@@ -56,10 +56,10 @@ def test_large_extension_operations_yield_between_batches():
|
||||
# batch loop (cancel-check -> batch call -> progress -> yield) was then
|
||||
# centralized into the processInBatches() helper in core/throttle.ts, so the
|
||||
# command modules call that instead of inlining the loop.
|
||||
core = (ROOT / "extension" / "src" / "core" / "throttle.ts").read_text()
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text()
|
||||
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
|
||||
core = (ROOT / "extension" / "src" / "core" / "throttle.ts").read_text(encoding="utf-8")
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text(encoding="utf-8")
|
||||
groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text(encoding="utf-8")
|
||||
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "yieldForLargeOperation" in core
|
||||
assert "getLargeOperationThrottle" in core
|
||||
@@ -105,10 +105,10 @@ def test_large_extension_operations_yield_between_batches():
|
||||
# and the command registry/groups during the class-based refactor. The
|
||||
# behavior is identical; assert the defining patterns still exist in their
|
||||
# new homes.
|
||||
jobs_module = (ROOT / "extension" / "src" / "classes" / "JobManager.ts").read_text()
|
||||
connection = (ROOT / "extension" / "src" / "classes" / "NativeConnection.ts").read_text()
|
||||
perf = (ROOT / "extension" / "src" / "commands" / "perf.ts").read_text()
|
||||
tabs_module = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
jobs_module = (ROOT / "extension" / "src" / "classes" / "JobManager.ts").read_text(encoding="utf-8")
|
||||
connection = (ROOT / "extension" / "src" / "classes" / "NativeConnection.ts").read_text(encoding="utf-8")
|
||||
perf = (ROOT / "extension" / "src" / "commands" / "perf.ts").read_text(encoding="utf-8")
|
||||
tabs_module = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "background: true" in tabs_module # background command flag (was BACKGROUND_COMMANDS)
|
||||
assert "persistJobs" in jobs_module
|
||||
@@ -120,8 +120,8 @@ def test_large_extension_operations_yield_between_batches():
|
||||
assert "__background" in connection
|
||||
|
||||
def test_tab_activation_open_and_merge_do_not_steal_audible_video_window():
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text(encoding="utf-8")
|
||||
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "await chrome.windows.update(tab.windowId, { focused: true });" not in tabs
|
||||
assert "active: Boolean(focus) && !background" in navigation
|
||||
@@ -151,8 +151,8 @@ def test_session_autosave_is_debounced_and_non_overlapping():
|
||||
# AutoSaveManager (autosave.ts) during the structure refactor; the shared
|
||||
# snapshot/signature helpers live in session-snapshot.ts. Behavior is
|
||||
# identical — the defining patterns just live in their new homes.
|
||||
autosave = (ROOT / "extension" / "src" / "commands" / "autosave.ts").read_text()
|
||||
snapshot = (ROOT / "extension" / "src" / "commands" / "session-snapshot.ts").read_text()
|
||||
autosave = (ROOT / "extension" / "src" / "commands" / "autosave.ts").read_text(encoding="utf-8")
|
||||
snapshot = (ROOT / "extension" / "src" / "commands" / "session-snapshot.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "autoSaveTimer" in autosave
|
||||
assert "autoSaveInFlight" in autosave
|
||||
@@ -172,12 +172,12 @@ def test_session_autosave_is_debounced_and_non_overlapping():
|
||||
assert "clearTimeout(this.autoSaveTimer)" in autosave
|
||||
|
||||
def test_cli_and_sdk_expose_gentle_restore_controls():
|
||||
session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text()
|
||||
tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text()
|
||||
groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text()
|
||||
commands_init = (ROOT / "browser_cli" / "commands" / "__init__.py").read_text()
|
||||
sdk_session = (ROOT / "browser_cli" / "sdk" / "session.py").read_text()
|
||||
sdk_perf = (ROOT / "browser_cli" / "sdk" / "perf.py").read_text()
|
||||
session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text(encoding="utf-8")
|
||||
tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text(encoding="utf-8")
|
||||
groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text(encoding="utf-8")
|
||||
commands_init = (ROOT / "browser_cli" / "commands" / "__init__.py").read_text(encoding="utf-8")
|
||||
sdk_session = (ROOT / "browser_cli" / "sdk" / "session.py").read_text(encoding="utf-8")
|
||||
sdk_perf = (ROOT / "browser_cli" / "sdk" / "perf.py").read_text(encoding="utf-8")
|
||||
|
||||
# The --gentle-mode flag is defined once via the shared gentle_mode_option helper.
|
||||
assert '"--gentle-mode"' in commands_init
|
||||
@@ -200,8 +200,8 @@ def test_cli_and_sdk_expose_gentle_restore_controls():
|
||||
assert "def status" in sdk_perf
|
||||
assert "def set_profile" in sdk_perf
|
||||
|
||||
perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text()
|
||||
root_cli = (ROOT / "browser_cli" / "cli.py").read_text()
|
||||
perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text(encoding="utf-8")
|
||||
root_cli = (ROOT / "browser_cli" / "cli.py").read_text(encoding="utf-8")
|
||||
assert "perf_group" in perf_cli
|
||||
assert "perf.status" in perf_cli
|
||||
assert "perf.set_profile" in perf_cli
|
||||
|
||||
@@ -15,6 +15,7 @@ from browser_cli.mcp.serialization import structured
|
||||
from browser_cli.mcp.naming import resolve_tool_prefix
|
||||
from browser_cli.mcp.server import _screenshot_bytes, create_server, main
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.version_manager import project_version
|
||||
|
||||
class FakeClient:
|
||||
instances: list["FakeClient"] = []
|
||||
@@ -147,6 +148,15 @@ async def test_environment_pins_all_calls_to_one_browser(client, monkeypatch):
|
||||
|
||||
assert FakeClient.instances[-1].target == ("testing", None, None)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_reports_the_project_version_to_clients(client):
|
||||
info = client.server_info
|
||||
|
||||
assert info is not None
|
||||
assert info.name == "browser-cli"
|
||||
assert info.version == project_version()
|
||||
assert info.version not in ("", "unknown")
|
||||
|
||||
def test_tool_prefix_defaults_to_browser_and_is_configurable():
|
||||
assert resolve_tool_prefix({}) == "browser_"
|
||||
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": ""}) == ""
|
||||
|
||||
@@ -14,9 +14,9 @@ def _raise_system_exit(code: int):
|
||||
def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
|
||||
alias = "work"
|
||||
socket_path = tmp_path / "work.sock"
|
||||
socket_path.write_text("")
|
||||
socket_path.write_text("", encoding="utf-8")
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
|
||||
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
|
||||
@@ -25,7 +25,7 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
|
||||
native_host._cleanup(alias)
|
||||
|
||||
assert not socket_path.exists()
|
||||
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
|
||||
assert json.loads(registry_path.read_text(encoding="utf-8")) == {"other": str(tmp_path / "other.sock")}
|
||||
|
||||
def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
||||
cleaned = []
|
||||
@@ -42,14 +42,14 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
||||
|
||||
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.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: True)
|
||||
|
||||
native_host._cleanup("work")
|
||||
|
||||
assert json.loads(registry_path.read_text()) == {}
|
||||
assert json.loads(registry_path.read_text(encoding="utf-8")) == {}
|
||||
|
||||
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
|
||||
cleaned = []
|
||||
|
||||
@@ -245,7 +245,7 @@ def test_serve_tcp_rate_limit_zero_disables():
|
||||
|
||||
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")
|
||||
keys.write_text("abc123 reader allow:read-page\ndef456 admin allow:all\nghi789 plain\n", encoding="utf-8")
|
||||
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()
|
||||
@@ -260,7 +260,7 @@ def test_auth_trust_writes_inline_policy_token(tmp_path):
|
||||
"--allow-read-page", "--allow-control",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
line = keys.read_text().strip()
|
||||
line = keys.read_text(encoding="utf-8").strip()
|
||||
assert line == f"{pub} ci bot allow:read-page,control"
|
||||
|
||||
def test_auth_trust_without_allow_flags_writes_no_token(tmp_path):
|
||||
@@ -268,11 +268,11 @@ def test_auth_trust_without_allow_flags_writes_no_token(tmp_path):
|
||||
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"
|
||||
assert keys.read_text(encoding="utf-8").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")
|
||||
keys.write_text(f"{'a' * 64} reader allow:read-page\n{'b' * 64} admin allow:all\n{'c' * 64} plain\n", encoding="utf-8")
|
||||
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
|
||||
assert result.exit_code == 0
|
||||
assert "Policy" in result.output
|
||||
@@ -286,37 +286,37 @@ def test_auth_keys_local_shows_policy_column(tmp_path):
|
||||
def test_auth_policy_updates_existing_key_policy(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
|
||||
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n", encoding="utf-8")
|
||||
result = CliRunner().invoke(main, [
|
||||
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
|
||||
assert keys.read_text(encoding="utf-8").strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
|
||||
assert "Updated policy" in result.output
|
||||
|
||||
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
keys.write_text(f"{pub} laptop allow:all\n")
|
||||
keys.write_text(f"{pub} laptop allow:all\n", encoding="utf-8")
|
||||
|
||||
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
|
||||
assert safe_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop allow:"
|
||||
assert keys.read_text(encoding="utf-8").strip() == f"{pub} laptop allow:"
|
||||
|
||||
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
|
||||
assert default_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop"
|
||||
assert keys.read_text(encoding="utf-8").strip() == f"{pub} laptop"
|
||||
|
||||
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
keys.write_text(f"{'a' * 64} laptop\n", encoding="utf-8")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose a policy mode" in result.output
|
||||
|
||||
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
keys.write_text(f"{'a' * 64} laptop\n", encoding="utf-8")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose exactly one policy mode" in result.output
|
||||
|
||||
+10
-10
@@ -74,7 +74,7 @@ class TestChallenge:
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
@@ -106,7 +106,7 @@ class TestRejection:
|
||||
def test_missing_pubkey_sig_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
client, t, _ = self._connect(path)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
@@ -119,7 +119,7 @@ class TestRejection:
|
||||
def test_untrusted_pubkey_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, trusted_pub = generate_keypair()
|
||||
path.write_text(trusted_pub + "\n")
|
||||
path.write_text(trusted_pub + "\n", encoding="utf-8")
|
||||
|
||||
pem, untrusted_pub = generate_keypair()
|
||||
key_path = tmp_path / "other.pem"
|
||||
@@ -140,7 +140,7 @@ class TestRejection:
|
||||
def test_bad_signature_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
client, t, _ = self._connect(path)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub, "sig": "00" * 64}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
@@ -154,7 +154,7 @@ class TestRejection:
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
priv_path = tmp_path / "client.pem"
|
||||
priv_path.write_bytes(pem)
|
||||
priv = load_private_key(priv_path)
|
||||
@@ -200,7 +200,7 @@ class TestAuthSuccess:
|
||||
"""Correct signature → error must be 'browser not connected', not 'unauthorized'."""
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
@@ -236,7 +236,7 @@ class TestAuthSuccess:
|
||||
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
@@ -276,7 +276,7 @@ class TestAuthSuccess:
|
||||
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
@@ -411,7 +411,7 @@ class TestPerKeyPolicy:
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
path.write_text(pub + "\n", encoding="utf-8")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
@@ -441,7 +441,7 @@ class TestPerKeyPolicy:
|
||||
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + " mykey\n")
|
||||
path.write_text(pub + " mykey\n", encoding="utf-8")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
@@ -64,39 +64,39 @@ def test_full_control_still_cannot_manage_keys():
|
||||
def test_set_policy_updates_by_pubkey(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
path.write_text(f"{pub} laptop\n")
|
||||
path.write_text(f"{pub} laptop\n", encoding="utf-8")
|
||||
assert set_authorized_key_policy(path, pub, ["control"]) == (pub, "laptop")
|
||||
assert load_authorized_keys_with_policies(path) == [(pub, "laptop", ["control"])]
|
||||
|
||||
def test_set_policy_by_name_and_remove_with_none(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
path.write_text(f"{pub} ci-bot allow:all\n")
|
||||
path.write_text(f"{pub} ci-bot allow:all\n", encoding="utf-8")
|
||||
assert set_authorized_key_policy(path, "ci-bot", None) == (pub, "ci-bot") # remove token
|
||||
assert load_authorized_keys_with_policies(path) == [(pub, "ci-bot", None)]
|
||||
|
||||
def test_set_policy_safe_only_writes_empty_token(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "c" * 64
|
||||
path.write_text(f"{pub} reader\n")
|
||||
path.write_text(f"{pub} reader\n", encoding="utf-8")
|
||||
set_authorized_key_policy(path, pub, [])
|
||||
assert path.read_text().strip() == f"{pub} reader allow:"
|
||||
assert path.read_text(encoding="utf-8").strip() == f"{pub} reader allow:"
|
||||
|
||||
def test_set_policy_not_found_returns_none(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(f"{'a' * 64} laptop\n")
|
||||
path.write_text(f"{'a' * 64} laptop\n", encoding="utf-8")
|
||||
assert set_authorized_key_policy(path, "nonexistent", ["control"]) is None
|
||||
|
||||
def test_set_policy_ambiguous_name_raises(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n")
|
||||
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="ambiguous"):
|
||||
set_authorized_key_policy(path, "dup", ["control"])
|
||||
|
||||
def test_set_policy_preserves_other_lines(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
a, b = "a" * 64, "b" * 64
|
||||
path.write_text(f"{a} first\n{b} second allow:read-page\n")
|
||||
path.write_text(f"{a} first\n{b} second allow:read-page\n", encoding="utf-8")
|
||||
set_authorized_key_policy(path, a, ["control"])
|
||||
assert load_authorized_keys_with_policies(path) == [
|
||||
(a, "first", ["control"]),
|
||||
@@ -139,7 +139,8 @@ def test_key_policies_from_authorized_keys(tmp_path):
|
||||
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
|
||||
"112233 readonly\n", # no allow token → no override entry
|
||||
encoding="utf-8",
|
||||
)
|
||||
policies = key_policies_from_authorized_keys(path)
|
||||
assert policies["aabbcc"] == CommandPolicy.unrestricted() # normalised to lowercase
|
||||
@@ -151,13 +152,13 @@ def test_key_policies_none_returns_empty():
|
||||
|
||||
def test_key_policies_rejects_unknown_category(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("abc name allow:bogus\n")
|
||||
path.write_text("abc name allow:bogus\n", encoding="utf-8")
|
||||
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")
|
||||
path.write_text("abc my laptop allow:control\n", encoding="utf-8")
|
||||
assert load_authorized_keys_with_names(path) == [("abc", "my laptop")]
|
||||
assert load_authorized_keys_with_policies(path) == [("abc", "my laptop", ["control"])]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user