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

- 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:
2026-10-20 12:41:25 +02:00
parent 7cb034dfc2
commit 69387c1054
19 changed files with 155 additions and 88 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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)
+26 -26
View File
@@ -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
+10
View File
@@ -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": ""}) == ""
+5 -5
View File
@@ -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 = []
+11 -11
View File
@@ -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
View File
@@ -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)
+11 -10
View File
@@ -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"])]