Files
Octop/tests/unit/bridge/test_update_connection.py
jubaoliang 5aa7847928 fix(bridge): keep simultaneous peer logins and tunnel chat attachments (#1986)
Distinct logins to the same account were collapsed onto one socket, and remote expert uploads never matched the tunnel allowlist. Inbound names stay local, and larger files split across frames once both sides negotiate it.
2026-10-13 17:19:04 +08:00

301 lines
10 KiB
Python

"""Unit tests for BridgeManager.update_connection_meta endpoint edits."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from octop.infra.bridge.manager import BridgeManager
from octop.infra.db.repos.bridge_connections import BridgeConnectionRow
from octop.infra.errors import ErrorCode, OctopError
def _row(**overrides: Any) -> BridgeConnectionRow:
base = {
"pk": 1,
"connection_id": "cid1",
"owner_user_id": 1,
"peer_base_url": "https://peer.example",
"peer_username": "alice",
"display_name": "云端",
"notes": None,
"icon_name": None,
"credential_blob": b"enc-pass",
"access_token_blob": b"enc-token",
"token_expires_at": None,
"status": "connected",
"last_error": None,
"last_seen_at": None,
"auto_reconnect": True,
"created_at": 1,
"updated_at": 1,
}
base.update(overrides)
return BridgeConnectionRow(**base) # type: ignore[arg-type]
def _mgr() -> BridgeManager:
return BridgeManager(
bridge_repo=MagicMock(),
secret_repo=MagicMock(),
user_repo=MagicMock(),
advertise_base_url="http://local.test",
)
@pytest.mark.asyncio
async def test_update_meta_only_skips_reauth(monkeypatch: pytest.MonkeyPatch) -> None:
mgr = _mgr()
row = _row()
updated = _row(display_name="新名", notes="n1")
mgr.get_owned = MagicMock(return_value=row) # type: ignore[method-assign]
mgr._repo.find_by_display_name = MagicMock(return_value=None)
mgr._repo.update_settings = MagicMock(return_value=updated)
login = AsyncMock()
monkeypatch.setattr("octop.infra.bridge.manager.login_peer", login)
out = await mgr.update_connection_meta(
"cid1",
owner_user_id=1,
display_name="新名",
notes="n1",
update_notes=True,
peer_base_url="https://peer.example",
peer_username="alice",
password="",
)
assert out.display_name == "新名"
login.assert_not_awaited()
mgr._repo.update_settings.assert_called_once()
assert mgr._repo.update_settings.call_args.kwargs.get("update_credentials") is not True
@pytest.mark.asyncio
async def test_update_endpoint_reauths_and_reconnects(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mgr = _mgr()
row = _row()
after = _row(peer_base_url="https://other.example", peer_username="bob")
mgr.get_owned = MagicMock(return_value=row) # type: ignore[method-assign]
mgr._repo.find_by_display_name = MagicMock(return_value=None)
mgr._repo.list_for_owner = MagicMock(return_value=[])
mgr._repo.update_settings = MagicMock(return_value=after)
mgr._repo.get = MagicMock(return_value=after)
mgr.disconnect = AsyncMock() # type: ignore[method-assign]
mgr.connect = AsyncMock(return_value=after) # type: ignore[method-assign]
login = AsyncMock(return_value={"access_token": "tok", "expires_in": 3600})
monkeypatch.setattr("octop.infra.bridge.manager.login_peer", login)
monkeypatch.setattr(
"octop.infra.bridge.manager.normalize_peer_base_url",
lambda raw: raw.strip().rstrip("/"),
)
monkeypatch.setattr(
"octop.infra.bridge.manager.encrypt_payload",
lambda _repo, payload: f"enc:{payload}".encode(),
)
monkeypatch.setattr(
"octop.infra.bridge.manager.decrypt_payload",
lambda _repo, _blob: {"password": "old-secret"},
)
out = await mgr.update_connection_meta(
"cid1",
owner_user_id=1,
display_name="云端",
peer_base_url="https://other.example",
peer_username="bob",
password=None,
)
assert out.peer_base_url == "https://other.example"
login.assert_awaited_once()
assert login.await_args.kwargs["username"] == "bob"
assert login.await_args.kwargs["password"] == "old-secret"
mgr.disconnect.assert_awaited_once_with("cid1")
mgr.connect.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_endpoint_rejects_duplicate_peer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mgr = _mgr()
row = _row()
other = _row(
connection_id="cid2",
display_name="另一台",
peer_base_url="https://other.example",
peer_username="bob",
)
mgr.get_owned = MagicMock(return_value=row) # type: ignore[method-assign]
mgr._repo.find_by_display_name = MagicMock(return_value=None)
mgr._repo.list_for_owner = MagicMock(return_value=[row, other])
monkeypatch.setattr(
"octop.infra.bridge.manager.normalize_peer_base_url",
lambda raw: raw.strip().rstrip("/"),
)
with pytest.raises(OctopError) as ei:
await mgr.update_connection_meta(
"cid1",
owner_user_id=1,
peer_base_url="https://other.example/",
peer_username="Bob",
password="secret",
)
assert ei.value.code == ErrorCode.BRIDGE_PEER_ALREADY_LINKED
mgr._repo.update_settings.assert_not_called()
def test_connection_public_marks_inbound() -> None:
mgr = _mgr()
inbound = mgr.connection_public(_row(credential_blob=None))
assert inbound["inbound"] is True
assert inbound["has_password"] is False
outbound = mgr.connection_public(_row())
assert outbound["inbound"] is False
assert outbound["has_password"] is True
@pytest.mark.asyncio
async def test_update_password_required_when_no_stored_secret() -> None:
mgr = _mgr()
row = _row(credential_blob=None)
mgr.get_owned = MagicMock(return_value=row) # type: ignore[method-assign]
mgr._repo.find_by_display_name = MagicMock(return_value=None)
with pytest.raises(OctopError) as ei:
await mgr.update_connection_meta(
"cid1",
owner_user_id=1,
peer_base_url="https://other.example",
password="",
)
assert ei.value.code == ErrorCode.BRIDGE_INBOUND_PASSIVE
@pytest.mark.asyncio
async def test_inbound_update_display_only() -> None:
mgr = _mgr()
row = _row(credential_blob=None, auto_reconnect=False)
updated = _row(credential_blob=None, display_name="对端机", notes="n")
mgr.get_owned = MagicMock(return_value=row) # type: ignore[method-assign]
mgr._repo.find_by_display_name = MagicMock(return_value=None)
mgr._repo.update_settings = MagicMock(return_value=updated)
out = await mgr.update_connection_meta(
"cid1",
owner_user_id=1,
display_name="对端机",
notes="n",
update_notes=True,
peer_base_url=row.peer_base_url,
peer_username=row.peer_username,
password="",
)
assert out.display_name == "对端机"
mgr._repo.update_settings.assert_called_once()
@pytest.mark.asyncio
async def test_inbound_connect_rejected() -> None:
mgr = _mgr()
mgr.get_owned = MagicMock(return_value=_row(credential_blob=None)) # type: ignore[method-assign]
with pytest.raises(OctopError) as ei:
await mgr.connect("cid1", owner_user_id=1)
assert ei.value.code == ErrorCode.BRIDGE_INBOUND_PASSIVE
@pytest.mark.asyncio
async def test_inbound_auto_reconnect_rejected() -> None:
mgr = _mgr()
mgr.get_owned = MagicMock(return_value=_row(credential_blob=None)) # type: ignore[method-assign]
with pytest.raises(OctopError) as ei:
await mgr.set_auto_reconnect("cid1", owner_user_id=1, enabled=True)
assert ei.value.code == ErrorCode.BRIDGE_INBOUND_PASSIVE
@pytest.mark.asyncio
async def test_delete_notifies_peer_before_disconnect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mgr = _mgr()
mgr.get_owned = MagicMock(return_value=_row()) # type: ignore[method-assign]
sess = MagicMock()
sess.closed = False
sess.send_json = AsyncMock()
mgr._sessions["cid1"] = sess
mgr.disconnect = AsyncMock() # type: ignore[method-assign]
monkeypatch.setattr("octop.infra.bridge.manager.asyncio.sleep", AsyncMock())
await mgr.delete_connection("cid1", owner_user_id=1)
sess.send_json.assert_awaited_once_with({"type": "close", "reason": "deleted"})
mgr.disconnect.assert_awaited_once_with("cid1")
mgr._repo.delete.assert_called_once_with("cid1")
@pytest.mark.asyncio
async def test_finalize_inbound_offline_keeps_row() -> None:
from octop.infra.bridge.transport import BridgeSession
mgr = _mgr()
mgr._repo.get = MagicMock(return_value=_row(credential_blob=None))
sess = BridgeSession(connection_id="cid1", send_text=AsyncMock())
await mgr._finalize_inbound("cid1", sess)
mgr._repo.delete.assert_not_called()
mgr._repo.update_status.assert_called_once()
@pytest.mark.asyncio
async def test_finalize_inbound_deleted_drops_row() -> None:
from octop.infra.bridge.transport import BridgeSession
mgr = _mgr()
sess = BridgeSession(connection_id="cid1", send_text=AsyncMock())
sess.close_reason = "deleted"
await mgr._finalize_inbound("cid1", sess)
mgr._repo.delete.assert_called_once_with("cid1")
mgr._repo.update_status.assert_not_called()
def test_inbound_default_display_name_uses_ip_and_keeps_local_edits() -> None:
from octop.infra.bridge.manager import inbound_default_display_name
assert (
inbound_default_display_name(
connection_id="cid1",
client_host="203.0.113.8",
advertise_base_url="http://127.0.0.1:8088",
peer_username="alice",
)
== "203.0.113.8"
)
assert (
inbound_default_display_name(
connection_id="cid1",
client_host="127.0.0.1",
advertise_base_url="https://office.example",
peer_username="alice",
)
== "office.example"
)
assert (
inbound_default_display_name(
connection_id="cid1",
client_host="",
advertise_base_url="http://127.0.0.1:8088",
peer_username="alice",
)
== "alice"
)
# A later hello from the initiator must not replace a name chosen here.
assert (
inbound_default_display_name(
connection_id="cid1",
client_host="203.0.113.9",
advertise_base_url="https://other.example",
peer_username="alice",
existing_name="我改过的名",
)
== "我改过的名"
)
assert inbound_default_display_name(connection_id="cid1") == "cid1"