6fa931aa36
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.
653 lines
25 KiB
Python
653 lines
25 KiB
Python
"""Unit tests for the TCP serve layer (challenge-response auth, framing, rejection paths)."""
|
|
import json
|
|
import socket
|
|
import struct
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from browser_cli import transport
|
|
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, pq_decrypt, pq_encrypt, sign
|
|
from browser_cli.client import BrowserNotConnected
|
|
from browser_cli.commands.serve import _handle_client
|
|
|
|
FAKE_UA = "browser-cli/0.9.3"
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
|
sock.sendall(struct.pack("<I", len(data)) + data)
|
|
|
|
def _recv_framed(sock: socket.socket) -> dict:
|
|
raw = b""
|
|
while len(raw) < 4:
|
|
chunk = sock.recv(4 - len(raw))
|
|
if not chunk:
|
|
raise ConnectionError("socket closed before response header")
|
|
raw += chunk
|
|
n = struct.unpack("<I", raw)[0]
|
|
data = b""
|
|
while len(data) < n:
|
|
chunk = sock.recv(n - len(data))
|
|
if not chunk:
|
|
raise ConnectionError("socket closed mid-response")
|
|
data += chunk
|
|
return json.loads(data)
|
|
|
|
def _spawn(server_sock: socket.socket, auth_keys_path, security=None) -> threading.Thread:
|
|
t = threading.Thread(
|
|
target=_handle_client,
|
|
args=(server_sock, ("127.0.0.1", 9999), None, auth_keys_path, True, security),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
return t
|
|
|
|
def _pair():
|
|
return socket.socketpair()
|
|
|
|
def _mock_no_browser(*_args, **_kwargs):
|
|
raise BrowserNotConnected("no browser")
|
|
|
|
# ── challenge frame ────────────────────────────────────────────────────────────
|
|
|
|
class TestChallenge:
|
|
def test_challenge_sent_on_connect(self):
|
|
client, server = _pair()
|
|
t = _spawn(server, None)
|
|
challenge = _recv_framed(client)
|
|
assert challenge["type"] == "challenge"
|
|
assert "nonce" in challenge
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_challenge_includes_version_fields(self):
|
|
client, server = _pair()
|
|
t = _spawn(server, None)
|
|
challenge = _recv_framed(client)
|
|
assert "server_version" in challenge
|
|
assert "min_client_version" in challenge
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_challenge_advertises_post_quantum_kex_when_available(self, tmp_path, monkeypatch):
|
|
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")
|
|
|
|
client, server = _pair()
|
|
t = _spawn(server, path)
|
|
challenge = _recv_framed(client)
|
|
|
|
assert challenge["pq_kex"] == {"alg": "ML-KEM-768", "public_key": b"fake-public".hex()}
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
# ── rejection paths ────────────────────────────────────────────────────────────
|
|
|
|
class TestRejection:
|
|
def _connect(self, auth_keys_path):
|
|
client, server = _pair()
|
|
t = _spawn(server, auth_keys_path)
|
|
challenge = _recv_framed(client)
|
|
return client, t, challenge
|
|
|
|
def test_bad_user_agent_rejected(self):
|
|
client, t, _ = self._connect(None)
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "curl/7.88"}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
assert resp["success"] is False
|
|
assert "forbidden" in resp["error"].lower() or "client" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_missing_pubkey_sig_rejected(self, tmp_path):
|
|
path = tmp_path / "authorized_keys"
|
|
_, pub = generate_keypair()
|
|
path.write_text(pub + "\n")
|
|
client, t, _ = self._connect(path)
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
assert resp["success"] is False
|
|
assert "unauthorized" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_untrusted_pubkey_rejected(self, tmp_path):
|
|
path = tmp_path / "authorized_keys"
|
|
_, trusted_pub = generate_keypair()
|
|
path.write_text(trusted_pub + "\n")
|
|
|
|
pem, untrusted_pub = generate_keypair()
|
|
key_path = tmp_path / "other.pem"
|
|
key_path.write_bytes(pem)
|
|
priv = load_private_key(key_path)
|
|
|
|
client, t, challenge = self._connect(path)
|
|
nonce = bytes.fromhex(challenge["nonce"])
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": untrusted_pub}
|
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
assert resp["success"] is False
|
|
assert "untrusted" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_bad_signature_rejected(self, tmp_path):
|
|
path = tmp_path / "authorized_keys"
|
|
_, pub = generate_keypair()
|
|
path.write_text(pub + "\n")
|
|
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())
|
|
resp = _recv_framed(client)
|
|
assert resp["success"] is False
|
|
assert "signature" in resp["error"].lower() or "invalid" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_missing_post_quantum_kex_rejected_when_required(self, tmp_path, monkeypatch):
|
|
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")
|
|
priv_path = tmp_path / "client.pem"
|
|
priv_path.write_bytes(pem)
|
|
priv = load_private_key(priv_path)
|
|
|
|
client, t, challenge = self._connect(path)
|
|
nonce = bytes.fromhex(challenge["nonce"])
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5", "pubkey": pub}
|
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert resp["success"] is False
|
|
assert "post-quantum" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_oversized_message_rejected(self):
|
|
client, server = _pair()
|
|
t = _spawn(server, None)
|
|
_recv_framed(client) # consume challenge
|
|
client.sendall(struct.pack("<I", 33 * 1024 * 1024))
|
|
resp = _recv_framed(client)
|
|
assert resp["success"] is False
|
|
assert "too large" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_invalid_json_rejected(self):
|
|
client, server = _pair()
|
|
t = _spawn(server, None)
|
|
_recv_framed(client) # consume challenge
|
|
bad = b"this is not json {"
|
|
_send_framed(client, bad)
|
|
resp = _recv_framed(client)
|
|
assert resp["success"] is False
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
# ── auth success paths ─────────────────────────────────────────────────────────
|
|
|
|
class TestAuthSuccess:
|
|
def test_valid_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
|
"""Correct signature → error must be 'browser not connected', not 'unauthorized'."""
|
|
path = tmp_path / "authorized_keys"
|
|
pem, pub = generate_keypair()
|
|
path.write_text(pub + "\n")
|
|
key_path = tmp_path / "client.key.pem"
|
|
key_path.write_bytes(pem)
|
|
priv = load_private_key(key_path)
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
|
|
client, server = _pair()
|
|
t = threading.Thread(
|
|
target=_handle_client,
|
|
args=(server, ("127.0.0.1", 9999), None, path),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
|
|
challenge = _recv_framed(client)
|
|
nonce = bytes.fromhex(challenge["nonce"])
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
|
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert resp["success"] is False
|
|
assert "unauthorized" not in resp["error"].lower()
|
|
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
|
|
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
|
|
path = tmp_path / "authorized_keys"
|
|
pem, pub = generate_keypair() # pub is lowercase hex
|
|
path.write_text(pub + "\n")
|
|
key_path = tmp_path / "client.key.pem"
|
|
key_path.write_bytes(pem)
|
|
priv = load_private_key(key_path)
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
|
|
client, server = _pair()
|
|
t = _spawn(server, path)
|
|
|
|
challenge = _recv_framed(client)
|
|
nonce = bytes.fromhex(challenge["nonce"])
|
|
# old client sends uppercase pubkey
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
|
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert "unauthorized" not in resp.get("error", "").lower()
|
|
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
|
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
|
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
|
|
|
path = tmp_path / "authorized_keys"
|
|
pem, pub = generate_keypair()
|
|
path.write_text(pub + "\n")
|
|
key_path = tmp_path / "client.key.pem"
|
|
key_path.write_bytes(pem)
|
|
priv = load_private_key(key_path)
|
|
|
|
client, server = _pair()
|
|
t = threading.Thread(
|
|
target=_handle_client,
|
|
args=(server, ("127.0.0.1", 9999), None, path),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
|
|
challenge = _recv_framed(client)
|
|
nonce = bytes.fromhex(challenge["nonce"])
|
|
msg = {
|
|
"id": "x",
|
|
"command": "tabs.list",
|
|
"args": {},
|
|
"user_agent": FAKE_UA,
|
|
"pubkey": pub,
|
|
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
|
|
}
|
|
msg["sig"] = sign(priv, nonce, msg, b"pq-secret").hex()
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert "unauthorized" not in resp.get("error", "").lower()
|
|
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_post_quantum_encrypted_transport_reaches_proxy(self, tmp_path, monkeypatch):
|
|
"""New clients encrypt the command payload and receive encrypted responses."""
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
|
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
|
|
|
path = tmp_path / "authorized_keys"
|
|
pem, pub = generate_keypair()
|
|
path.write_text(pub + "\n")
|
|
key_path = tmp_path / "client.key.pem"
|
|
key_path.write_bytes(pem)
|
|
priv = load_private_key(key_path)
|
|
|
|
client, server = _pair()
|
|
t = threading.Thread(
|
|
target=_handle_client,
|
|
args=(server, ("127.0.0.1", 9999), None, path),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
|
|
challenge = _recv_framed(client)
|
|
nonce = bytes.fromhex(challenge["nonce"])
|
|
clean_msg = {
|
|
"id": "x",
|
|
"command": "tabs.list",
|
|
"args": {},
|
|
"user_agent": "browser-cli/0.9.5",
|
|
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
|
|
}
|
|
sig = sign(priv, nonce, clean_msg, b"pq-secret").hex()
|
|
envelope = {
|
|
"id": "x",
|
|
"user_agent": "browser-cli/0.9.5",
|
|
"pubkey": pub,
|
|
"sig": sig,
|
|
"pq_kex": clean_msg["pq_kex"],
|
|
"encrypted": pq_encrypt(b"pq-secret", "request", json.dumps(clean_msg).encode()),
|
|
}
|
|
_send_framed(client, json.dumps(envelope).encode())
|
|
encrypted_resp = _recv_framed(client)
|
|
|
|
assert "encrypted" in encrypted_resp
|
|
resp = json.loads(pq_decrypt(b"pq-secret", "response", encrypted_resp["encrypted"]))
|
|
assert "unauthorized" not in resp.get("error", "").lower()
|
|
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_no_auth_mode_reaches_proxy(self, monkeypatch):
|
|
"""auth_keys_path=None (--no-auth): no pubkey required, reaches proxy layer."""
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
|
|
client, server = _pair()
|
|
t = threading.Thread(
|
|
target=_handle_client,
|
|
args=(server, ("127.0.0.1", 9999), None, None),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
|
|
_recv_framed(client) # challenge
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert "unauthorized" not in resp.get("error", "").lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
# ── command policy gating ────────────────────────────────────────────────────────
|
|
|
|
class TestCommandPolicy:
|
|
def test_restricted_policy_blocks_dangerous_command(self, monkeypatch):
|
|
"""A restricted policy denies dom.eval before it ever reaches the browser proxy."""
|
|
from browser_cli.command_security import CommandPolicy
|
|
from browser_cli.serve.security import ServeSecurity
|
|
|
|
# If the policy were not enforced, this would be hit and raise a different error.
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
|
|
client, server = _pair()
|
|
t = _spawn(server, None, ServeSecurity(policy=CommandPolicy())) # safe-only
|
|
_recv_framed(client) # challenge
|
|
msg = {"id": "x", "command": "dom.eval", "args": {"code": "1"}, "user_agent": "browser-cli/0.9.5"}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert resp["success"] is False
|
|
assert "dangerous" in resp["error"].lower() and "blocked" in resp["error"].lower()
|
|
assert "browser" not in resp["error"].lower() # never reached the proxy
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_restricted_policy_allows_safe_command(self, monkeypatch):
|
|
"""Safe commands pass the policy gate and reach the proxy even when restricted."""
|
|
from browser_cli.command_security import CommandPolicy
|
|
from browser_cli.serve.security import ServeSecurity
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
client, server = _pair()
|
|
t = _spawn(server, None, ServeSecurity(policy=CommandPolicy())) # safe-only
|
|
_recv_framed(client)
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert resp["success"] is False
|
|
assert "blocked" not in resp["error"].lower()
|
|
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_unrestricted_policy_allows_dangerous_command(self, monkeypatch):
|
|
"""The default unrestricted policy lets dom.eval through to the proxy."""
|
|
from browser_cli.command_security import CommandPolicy
|
|
from browser_cli.serve.security import ServeSecurity
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
client, server = _pair()
|
|
t = _spawn(server, None, ServeSecurity(policy=CommandPolicy.unrestricted()))
|
|
_recv_framed(client)
|
|
msg = {"id": "x", "command": "dom.eval", "args": {"code": "1"}, "user_agent": "browser-cli/0.9.5"}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert resp["success"] is False
|
|
assert "blocked" not in resp["error"].lower()
|
|
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
# ── per-key authorization + rate limiting (integration) ──────────────────────────
|
|
|
|
class TestPerKeyPolicy:
|
|
def test_per_key_policy_overrides_server_default(self, tmp_path, monkeypatch):
|
|
"""A safe-only per-key override blocks dom.eval even when the server default is unrestricted."""
|
|
from browser_cli.command_security import CommandPolicy
|
|
from browser_cli.serve.security import ServeSecurity
|
|
|
|
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")
|
|
key_path = tmp_path / "client.key.pem"
|
|
key_path.write_bytes(pem)
|
|
priv = load_private_key(key_path)
|
|
|
|
security = ServeSecurity(
|
|
policy=CommandPolicy.unrestricted(), # server default: full access
|
|
key_policies={pub.lower(): CommandPolicy()}, # this key: safe-only
|
|
)
|
|
|
|
client, server = _pair()
|
|
t = _spawn(server, path, security)
|
|
challenge = _recv_framed(client)
|
|
nonce = bytes.fromhex(challenge["nonce"])
|
|
msg = {"id": "x", "command": "dom.eval", "args": {"code": "1"}, "user_agent": FAKE_UA, "pubkey": pub}
|
|
msg["sig"] = sign(priv, nonce, msg).hex()
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
|
|
assert resp["success"] is False
|
|
assert "blocked" in resp["error"].lower() # per-key safe-only wins over server unrestricted
|
|
client.close()
|
|
t.join(timeout=2)
|
|
|
|
class TestRateLimit:
|
|
def test_shared_rate_limiter_blocks_second_command(self, monkeypatch):
|
|
"""A burst-1 limiter shared across connections allows the first command, denies the next."""
|
|
from browser_cli.command_security import CommandPolicy
|
|
from browser_cli.serve.security import RateLimiter, ServeSecurity
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
# rate tiny so the bucket never refills within the test; burst 1 = one command total.
|
|
security = ServeSecurity(policy=CommandPolicy.unrestricted(), rate_limiter=RateLimiter(rate=0.001, burst=1))
|
|
|
|
def one_command():
|
|
client, server = _pair()
|
|
t = _spawn(server, None, security) # no-auth → keyed by address (127.0.0.1)
|
|
_recv_framed(client)
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
resp = _recv_framed(client)
|
|
client.close()
|
|
t.join(timeout=2)
|
|
return resp
|
|
|
|
first = one_command()
|
|
second = one_command()
|
|
assert "rate limit" not in (first.get("error") or "").lower()
|
|
assert "rate limit" in (second.get("error") or "").lower()
|
|
|
|
# ── response encoding (compression / msgpack) ───────────────────────────────────
|
|
|
|
def _recv_framed_raw(sock: socket.socket) -> bytes:
|
|
raw = b""
|
|
while len(raw) < 4:
|
|
chunk = sock.recv(4 - len(raw))
|
|
if not chunk:
|
|
raise ConnectionError("socket closed before response header")
|
|
raw += chunk
|
|
n = struct.unpack("<I", raw)[0]
|
|
data = b""
|
|
while len(data) < n:
|
|
chunk = sock.recv(n - len(data))
|
|
if not chunk:
|
|
raise ConnectionError("socket closed mid-response")
|
|
data += chunk
|
|
return data
|
|
|
|
class _FakeNativeHost:
|
|
"""Minimal AF_UNIX server speaking the 4-byte framed protocol the proxy expects."""
|
|
|
|
def __init__(self, sock_path, response_obj):
|
|
self.sock_path = str(sock_path)
|
|
self.response = json.dumps(response_obj).encode()
|
|
self.srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.srv.bind(self.sock_path)
|
|
self.srv.listen(1)
|
|
self.thread = threading.Thread(target=self._serve, daemon=True)
|
|
self.thread.start()
|
|
|
|
def _serve(self):
|
|
try:
|
|
conn, _ = self.srv.accept()
|
|
with conn:
|
|
hdr = conn.recv(4)
|
|
n = struct.unpack("<I", hdr)[0]
|
|
got = b""
|
|
while len(got) < n:
|
|
got += conn.recv(n - len(got))
|
|
conn.sendall(struct.pack("<I", len(self.response)) + self.response)
|
|
except OSError:
|
|
pass
|
|
|
|
def close(self):
|
|
self.srv.close()
|
|
|
|
class TestResponseEncoding:
|
|
def test_client_accept_encoding_yields_decodable_tagged_response(self, tmp_path, monkeypatch):
|
|
if socket.AF_UNIX is None: # pragma: no cover
|
|
pytest.skip("AF_UNIX unavailable")
|
|
|
|
big = {"id": "x", "success": True,
|
|
"data": {"items": [{"url": f"https://example.com/{i}", "title": f"Tab {i}"} for i in range(300)]}}
|
|
host_path = tmp_path / "native.sock"
|
|
host = _FakeNativeHost(host_path, big)
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", lambda *_a, **_k: str(host_path))
|
|
|
|
client, server = _pair()
|
|
t = _spawn(server, None) # no auth
|
|
_recv_framed(client) # challenge
|
|
|
|
msg = {
|
|
"id": "x", "command": "tabs.list", "args": {},
|
|
"user_agent": "browser-cli/0.9.5",
|
|
"accept_encoding": {"ser": ["json"], "comp": ["gzip"]},
|
|
}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
raw = _recv_framed_raw(client)
|
|
|
|
assert raw[:1] not in (b"{", b"[") # tagged, not plain JSON
|
|
assert len(raw) < len(json.dumps(big)) # compressed
|
|
assert transport.decode_response(raw) == big
|
|
client.close()
|
|
host.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_no_accept_encoding_stays_plain_json(self, tmp_path, monkeypatch):
|
|
big = {"id": "y", "success": True, "data": {"items": list(range(500))}}
|
|
host_path = tmp_path / "native2.sock"
|
|
host = _FakeNativeHost(host_path, big)
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", lambda *_a, **_k: str(host_path))
|
|
|
|
client, server = _pair()
|
|
t = _spawn(server, None)
|
|
_recv_framed(client)
|
|
|
|
msg = {"id": "y", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5"}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
raw = _recv_framed_raw(client)
|
|
|
|
assert raw[:1] in (b"{", b"[") # old client → plain JSON
|
|
assert json.loads(raw) == big
|
|
client.close()
|
|
host.close()
|
|
t.join(timeout=2)
|
|
|
|
def test_no_compress_flag_forces_plain_json(self, tmp_path, monkeypatch):
|
|
big = {"id": "z", "success": True,
|
|
"data": {"items": [{"url": f"https://e/{i}"} for i in range(300)]}}
|
|
host_path = tmp_path / "native3.sock"
|
|
host = _FakeNativeHost(host_path, big)
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", lambda *_a, **_k: str(host_path))
|
|
|
|
client, server = _pair()
|
|
t = threading.Thread(
|
|
target=_handle_client,
|
|
args=(server, ("127.0.0.1", 9999), None, None, False), # compress=False
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
_recv_framed(client)
|
|
|
|
msg = {"id": "z", "command": "tabs.list", "args": {},
|
|
"user_agent": "browser-cli/0.9.5",
|
|
"accept_encoding": {"ser": ["msgpack"], "comp": ["zstd"]}}
|
|
_send_framed(client, json.dumps(msg).encode())
|
|
raw = _recv_framed_raw(client)
|
|
|
|
assert raw[:1] in (b"{", b"[") # server disabled encoding
|
|
assert json.loads(raw) == big
|
|
client.close()
|
|
host.close()
|
|
t.join(timeout=2)
|
|
|
|
# ── async serve path ─────────────────────────────────────────────────────────
|
|
|
|
def test_async_handle_client_sends_challenge_and_proxies_no_auth(monkeypatch):
|
|
"""Async TCP handler mirrors the sync challenge + proxy error path."""
|
|
import asyncio
|
|
from browser_cli.commands import serve as serve_mod
|
|
|
|
async def run():
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
|
|
|
async def handle(reader, writer):
|
|
await serve_mod._async_handle_client(
|
|
reader,
|
|
writer,
|
|
("127.0.0.1", 9999),
|
|
None,
|
|
None,
|
|
True,
|
|
asyncio.Semaphore(64),
|
|
)
|
|
|
|
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)
|
|
challenge = json.loads(await serve_mod._async_recv_all(reader))
|
|
assert challenge["type"] == "challenge"
|
|
|
|
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
|
await serve_mod._async_framed_send(writer, json.dumps(msg).encode())
|
|
resp = json.loads(await serve_mod._async_recv_all(reader))
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
assert resp["success"] is False
|
|
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
|
|
|
asyncio.run(run())
|