"""无桌面入口的进程退出与现有数据保护。""" import subprocess import sys import tempfile import threading import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch from peach import server, settings_file from starlette.requests import Request class ServerTests(unittest.TestCase): def test_reader_initialization_does_not_launch_a_ledger_migration(self): with tempfile.TemporaryDirectory() as directory: config = settings_file.load_config(environ={'PEACH_DATA_ROOT': str(Path(directory).resolve())}) settings_file.write(config) config.directory('database').mkdir() database = config.directory('database') / 'ledger.db' database.write_bytes(b'unchanged reader ledger') config = settings_file.load_config(environ={'PEACH_DATA_ROOT': str(config.data_root)}) with patch.object(server.settings_file, 'load_config', return_value=config), patch( 'peach.runtime_prepare.configured_ledger_writer', return_value=(False, '读取端')), \ patch.object(server, 'launch') as launch: server.initialize() launch.assert_not_called() self.assertEqual(database.read_bytes(), b'unchanged reader ledger') def test_headless_configuration_accepts_private_peers_and_rejects_forwarding(self): from peach.routes_configuration import local_client, managed_configuration with patch.dict("os.environ", {"PEACH_HEADLESS_MANAGED": "1"}): self.assertTrue(managed_configuration()) for peer, headers, allowed in ( ("192.0.2.1", [], True), ("198.51.100.10", [], True), ("8.8.8.8", [], False), ("169.254.1.1", [], False), ("0.0.0.0", [], False), ("240.0.0.1", [], False), ("127.0.0.1", [], True), ("fd00::1", [], True), ("192.0.2.1", [(b"x-forwarded-for", b"8.8.8.8")], False), ("192.0.2.1", [(b"x-forwarded-host", b"example.com")], False), ): request = Request({"type": "http", "client": (peer, 1234), "server": ("0.0.0.0", 8900), "headers": headers, "app": SimpleNamespace(state=SimpleNamespace(tunnel=None))}) with self.subTest(peer=peer, headers=headers): self.assertEqual(local_client(request), allowed) def test_cli_without_mdns_or_tunnel_needs_no_network_route(self): from peach import cli with patch.object(cli, "_require_readable_settings"), \ patch.object(cli, "_serve_token", return_value="test"), \ patch.object(cli, "_build_sync", return_value=None), \ patch.object(cli, "create_app"), patch.object(cli, "TUNNEL_ENABLED", False), \ patch("peach.mdns.lan_ipv4", side_effect=OSError("no route")) as route, \ patch.object(cli, "_run_uvicorn"): self.assertEqual(cli.main(["serve", "--host", "0.0.0.0", "--no-mdns"]), 0) route.assert_not_called() def test_every_served_listener_gets_the_handler_that_can_listen_again(self): """客户端在 accept 完成前断开、监听被标准库关掉时,处理器要拿着 uvicorn 的 Server 才能重新监听。""" import asyncio from peach import cli, error_reporting handlers = [] for arguments in (["serve", "--host", "0.0.0.0", "--no-mdns"], ["serve", "--host", "0.0.0.0", "--port", "80", "--redirect-origin", "https://peach.test"]): handlers.clear() with self.subTest(arguments=arguments), \ patch.object(cli, "_require_readable_settings"), \ patch.object(cli, "_serve_token", return_value="test"), \ patch.object(cli, "_build_sync", return_value=None), \ patch.object(cli, "create_app"), patch.object(cli, "TUNNEL_ENABLED", False), \ patch("uvicorn.Config") as config, patch("uvicorn.Server") as uvicorn_server: config.return_value.get_loop_factory.return_value = None uvicorn_server.return_value.serve = AsyncMock( side_effect=lambda: handlers.append(asyncio.get_running_loop().get_exception_handler())) self.assertEqual(cli.main(arguments), 0) handler, = handlers self.assertIs(handler.func, error_reporting.asyncio_handler) self.assertEqual(handler.keywords, {"uvicorn_server": uvicorn_server.return_value}) def test_initialization_preserves_existing_configuration(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() config = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(root)}) settings_file.write(config) config.directory('database').mkdir() config.directory('database').joinpath('ledger.db').touch() existing = config.path.read_bytes() with patch.object(server.settings_file, "load_config", return_value=settings_file.load_config( environ={"PEACH_DATA_ROOT": str(root)})), patch.object(server, "launch") as launch: launch.return_value.wait.return_value = 0 server.initialize() launch.assert_called_once_with('migrate', 'upgrade', '--yes', '--db', str(config.directory('database') / 'ledger.db')) self.assertEqual(config.path.read_bytes(), existing) def test_initialization_rejects_database_without_configuration(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() config = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(root)}) config.directory("database").mkdir() config.directory("database").joinpath("ledger.db").write_bytes(b"existing") with patch.object(server.settings_file, "load_config", return_value=config), \ patch.object(server, "launch") as launch: with self.assertRaisesRegex(RuntimeError, "已有数据库"): server.initialize() launch.assert_not_called() def test_stop_waits_for_child_exit(self): process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"], stdin=subprocess.DEVNULL) try: server.stop_process(process) self.assertIsNotNone(process.poll()) finally: server.stop_process(process) def test_relocation_restarts_the_service_on_the_new_data_root(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() config = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(root)}) process = subprocess.Popen([sys.executable, "-c", "raise SystemExit(17)"], stdin=subprocess.DEVNULL) process.wait(timeout=15) calls = [] def relocation(stop, restart): if calls: return False calls.append(True) stop() restart({"PEACH_DATA_ROOT": str(root / "moved")}) return True with patch.dict(server.os.environ, {}), \ patch.object(server.settings_file, "load_config", return_value=config), \ patch("peach.data_storage.poll_headless", side_effect=relocation), \ patch.object(server, "launch", return_value=process) as launch: self.assertEqual(server.supervise("127.0.0.1", 8900, threading.Event()), 17) # 迁移后看守进程自己也按新位置读设置,拉起的服务继承这个位置。 self.assertEqual(server.os.environ["PEACH_DATA_ROOT"], str(root / "moved")) self.assertEqual(launch.call_count, 1) def test_service_failure_exits_supervisor(self): with tempfile.TemporaryDirectory() as directory: config = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(Path(directory).resolve())}) process = subprocess.Popen([sys.executable, "-c", "raise SystemExit(17)"], stdin=subprocess.DEVNULL) process.wait(timeout=15) with patch.object(server.settings_file, "load_config", return_value=config), \ patch.object(server, "launch", return_value=process): self.assertEqual(server.supervise("127.0.0.1", 8900, threading.Event()), 17) def test_a_fresh_docker_volume_starts_unconfigured_without_init(self): with tempfile.TemporaryDirectory() as directory: config = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(Path(directory).resolve())}) with patch.dict(server.os.environ, {"PEACH_DISTRIBUTION": "docker"}), \ patch.object(server.settings_file, "load_config", return_value=config), \ patch.object(server, "launch") as launch: server.initialize() launch.assert_not_called() self.assertFalse(config.path.exists()) config.directory("database").mkdir(parents=True) config.directory("database").joinpath("ledger.db").write_bytes(b"existing") with self.assertRaisesRegex(RuntimeError, "已有数据库"): server.initialize() launch.assert_not_called() def test_the_setup_service_is_replaced_once_the_settings_file_appears(self): from dataclasses import replace from unittest.mock import Mock with tempfile.TemporaryDirectory() as directory: absent = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(Path(directory).resolve())}) present = replace(absent, present=True) state = {"configured": False} stopping = threading.Event() launched = [] def start(*arguments): launched.append(arguments) if len(launched) == 1: state["configured"] = True # 首次设置提交,设置文件写好了 else: stopping.set() return Mock(poll=Mock(return_value=None)) with patch.object(server.settings_file, "load_config", side_effect=lambda: present if state["configured"] else absent), \ patch("peach.data_storage.poll_headless", return_value=False), \ patch.object(server, "launch", side_effect=start): self.assertEqual(server.supervise("0.0.0.0", 8900, stopping), 0) self.assertEqual([arguments[:2] for arguments in launched], [("serve", "--setup"), ("serve", "--host")]) def test_access_password_from_the_environment_is_written_once_and_never_echoed(self): from peach import access with tempfile.TemporaryDirectory() as directory: config = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(Path(directory).resolve())}) path = config.directory("secrets") / "access.json" with patch.dict(server.os.environ, {server.ACCESS_PASSWORD_ENV: "short"}): message = server.seed_access_password(config) self.assertNotIn(server.ACCESS_PASSWORD_ENV, server.os.environ) self.assertIn("8–256", message) self.assertFalse(path.exists()) with patch.dict(server.os.environ, {server.ACCESS_PASSWORD_ENV: "container-secret"}): message = server.seed_access_password(config) self.assertNotIn(server.ACCESS_PASSWORD_ENV, server.os.environ, "不传给服务子进程") self.assertNotIn("container-secret", message) policy = access.load(path) self.assertEqual(policy["mode"], "password") self.assertTrue(access.verify(policy, "container-secret")) self.assertNotIn("container-secret", path.read_text(encoding="utf-8")) with patch.dict(server.os.environ, {server.ACCESS_PASSWORD_ENV: "another-secret"}): message = server.seed_access_password(config) self.assertIn("忽略", message) self.assertNotIn("another-secret", message) self.assertEqual(access.load(path)["revision"], policy["revision"]) with patch.dict(server.os.environ, {}, clear=False): server.os.environ.pop(server.ACCESS_PASSWORD_ENV, None) self.assertEqual(server.seed_access_password(config), "")