Files
dy/backend/tests/test_egress_channels.py
T
2026-09-01 15:31:05 +08:00

169 lines
6.0 KiB
Python

from __future__ import annotations
import asyncio
import os
import sys
import time
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.dialects import mysql
from sqlalchemy.schema import CreateTable
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from rpa_engine.egress_channels import (
EgressChannel,
EgressChannelUnavailable,
EgressSnapshot,
LocalAddress,
discover_egress_channels,
reset_egress_cache_for_tests,
resolve_send_channels,
)
from rpa_engine.source_bound_proxy import SourceBoundProxy
from models.db_migrate import migrate_accounts_table
from models.models import Account
class EgressChannelTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
reset_egress_cache_for_tests()
async def test_discovery_deduplicates_public_ip_and_keeps_bindable_source(self):
candidates = [
LocalAddress(None, "default", True),
LocalAddress("10.0.0.5", "eth0"),
LocalAddress("10.0.0.6", "eth0:1"),
]
async def probe(candidate):
public_ip = "203.0.113.10" if candidate.source_ip != "10.0.0.6" else "203.0.113.11"
return (
EgressChannel(
public_ip=public_ip,
source_ip=candidate.source_ip,
interface=candidate.interface,
is_default=candidate.is_default,
),
"",
)
with (
patch(
"rpa_engine.egress_channels.local_address_candidates",
return_value=candidates,
),
patch(
"rpa_engine.egress_channels._probe_local_address",
AsyncMock(side_effect=probe),
),
):
snapshot = await discover_egress_channels(force=True)
self.assertEqual([item.public_ip for item in snapshot.channels], ["203.0.113.10", "203.0.113.11"])
self.assertEqual(snapshot.channels[0].source_ip, "10.0.0.5")
self.assertTrue(snapshot.channels[0].is_default)
async def test_selected_channel_is_first_and_attempt_count_is_bounded(self):
snapshot = EgressSnapshot(
channels=(
EgressChannel("198.51.100.1", "10.0.0.1", "eth0", True),
EgressChannel("198.51.100.2", "10.0.0.2", "eth0:1"),
EgressChannel("198.51.100.3", "10.0.0.3", "eth0:2"),
),
errors=(),
detected_at=time.time(),
)
with patch(
"rpa_engine.egress_channels.discover_egress_channels",
AsyncMock(return_value=snapshot),
):
routes = await resolve_send_channels("198.51.100.2", 2)
self.assertEqual([item.public_ip for item in routes], ["198.51.100.2", "198.51.100.1"])
async def test_missing_selected_channel_fails_closed(self):
snapshot = EgressSnapshot(
channels=(EgressChannel("198.51.100.1", None, "default", True),),
errors=(),
detected_at=time.time(),
)
with patch(
"rpa_engine.egress_channels.discover_egress_channels",
AsyncMock(return_value=snapshot),
):
with self.assertRaises(EgressChannelUnavailable):
await resolve_send_channels("198.51.100.99", 2)
async def test_browser_proxy_binds_selected_source_address(self):
observed_peer = asyncio.get_running_loop().create_future()
async def target_handler(reader, writer):
if not observed_peer.done():
observed_peer.set_result(writer.get_extra_info("peername")[0])
payload = await reader.readexactly(4)
writer.write(payload)
await writer.drain()
writer.close()
await writer.wait_closed()
target = await asyncio.start_server(target_handler, "127.0.0.1", 0)
target_port = target.sockets[0].getsockname()[1]
proxy = await SourceBoundProxy("127.0.0.2").start()
writer = None
try:
reader, writer = await asyncio.open_connection(
"127.0.0.1",
int(proxy.server_url.rpartition(":")[2]),
)
writer.write(
(
f"CONNECT 127.0.0.1:{target_port} HTTP/1.1\r\n"
f"Host: 127.0.0.1:{target_port}\r\n\r\n"
).encode("ascii")
)
await writer.drain()
response = await reader.readuntil(b"\r\n\r\n")
self.assertIn(b"200 Connection Established", response)
writer.write(b"ping")
await writer.drain()
self.assertEqual(await reader.readexactly(4), b"ping")
self.assertEqual(await asyncio.wait_for(observed_peer, 1), "127.0.0.2")
finally:
if writer is not None:
writer.close()
await writer.wait_closed()
await proxy.close()
target.close()
await target.wait_closed()
class EgressMigrationTests(unittest.TestCase):
def test_mysql_accounts_uses_longtext_for_browser_payloads(self):
ddl = str(CreateTable(Account.__table__).compile(dialect=mysql.dialect()))
self.assertIn("cookie_data LONGTEXT", ddl)
self.assertIn("im_session_data LONGTEXT", ddl)
self.assertIn("qr_code_base64 LONGTEXT", ddl)
def test_old_accounts_table_receives_egress_columns(self):
engine = create_engine("sqlite:///:memory:")
with engine.begin() as connection:
connection.execute(text("CREATE TABLE accounts (id INTEGER PRIMARY KEY)"))
migrate_accounts_table(connection)
columns = {item["name"] for item in inspect(connection).get_columns("accounts")}
self.assertIn("egress_public_ip", columns)
self.assertIn("egress_auto_attempts", columns)
if __name__ == "__main__":
unittest.main()