"""诊断使用临时账本和数据目录,不执行自动修复。""" import io import json import sqlite3 import socket import errno import tempfile import threading import unittest from contextlib import closing, redirect_stdout from dataclasses import replace from pathlib import Path from unittest.mock import patch from peach import cli, diagnostics, health from peach.config import PeachSettings from peach.ffmpeg import BinaryChoice from peach.migrations import upgrade from peach.settings_file import PeachConfig, ServerSettings ROOT = Path(__file__).resolve().parents[1] EMPTY_MOUNTS = {"state": "ok", "sources": [], "warnings": []} class DiagnosticsTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name).resolve() self.db = self.root / "ledger.db" self.page = self.root / "index.html" self.page.write_text("Peach", encoding="utf-8") self.config = PeachConfig(self.root, self.root / "config.toml", data_root_found=True, server=ServerSettings(host="127.0.0.1", port=0)) self.settings = PeachSettings(db_path=self.db, page_path=self.page, host="127.0.0.1", port=0, configured=True, access_path=self.root / "access.json", token="private-token", ffmpeg_root=self.root / "tools") def inspect(self): return diagnostics.report(self.settings, EMPTY_MOUNTS, config=self.config) def test_missing_empty_and_corrupt_databases_are_distinct_and_never_created(self): self.assertEqual(health.inspect_database(self.db)["database"], "missing") self.assertFalse(self.db.exists()) sqlite3.connect(self.db).close() self.assertEqual(health.inspect_database(self.db)["database"], "empty") self.db.write_bytes(b"invalid database") self.assertEqual(health.inspect_database(self.db)["database"], "unavailable") def test_schema_checks_pending_checksum_and_unknown_versions_without_migration(self): upgrade(self.db, ROOT / "migrations") self.assertEqual(health.inspect_database(self.db)["schema"], "current") with closing(sqlite3.connect(self.db)) as connection, connection: connection.execute("DELETE FROM schema_migration WHERE version='0043'") self.assertEqual(health.inspect_database(self.db)["pending"], ["0043"]) with closing(sqlite3.connect(self.db)) as connection, connection: connection.execute("UPDATE schema_migration SET checksum='incorrect' WHERE version='0001'") self.assertEqual(health.inspect_database(self.db)["mismatched"], ["0001"]) with closing(sqlite3.connect(self.db)) as connection, connection: connection.execute("INSERT INTO schema_migration(version,name,checksum,applied_at) VALUES(?,?,?,?)", ("private-token", "secret", "secret", "2026-10-03")) inspected = health.inspect_database(self.db) self.assertEqual(inspected["unknown_versions"], 1) self.assertNotIn("private-token", json.dumps(inspected)) before = self.db.read_bytes() result = self.inspect() self.assertEqual(result["checks"]["schema"]["status"], "failed") self.assertEqual(self.db.read_bytes(), before) def test_a_malformed_configuration_is_reported_without_its_sensitive_text(self): self.config.path.write_text('secret = "private-token\n', encoding="utf-8") result = self.inspect() self.assertEqual(result["checks"]["configured"]["status"], "failed") self.assertNotIn("private-token", json.dumps(result)) self.assertNotIn(str(self.root), json.dumps(result)) def test_writable_probe_cleans_up_and_permission_failure_has_a_specific_reason(self): before = set(self.root.iterdir()) self.assertEqual(diagnostics.writable_root(self.root)["status"], "ok") self.assertEqual(set(self.root.iterdir()), before) with patch.object(diagnostics.tempfile, "TemporaryFile", side_effect=PermissionError): self.assertEqual(diagnostics.writable_root(self.root)["reason"], "没有权限写入数据目录") def test_security_reports_anonymous_lan_and_password_without_secrets(self): from peach.access import save save(self.settings.access_path, "") settings = replace(self.settings, host="0.0.0.0") self.assertEqual(diagnostics.security(settings)["status"], "warning") save(settings.access_path, "my-private-password") result = diagnostics.security(settings) self.assertEqual(result["status"], "ok") self.assertNotIn("my-private-password", json.dumps(result)) self.assertNotIn("hash", result["details"]) def test_corrupt_access_policy_and_missing_tools_are_explained(self): self.settings.access_path.write_text("broken", encoding="utf-8") self.assertEqual(diagnostics.security(self.settings)["status"], "failed") with patch.object(diagnostics.FFmpegResolver, "ffmpeg", return_value=None), \ patch.object(diagnostics.FFmpegResolver, "ffprobe", return_value=None), \ patch.object(diagnostics.shutil, "which", return_value=None): result = diagnostics.tools(self.settings) self.assertEqual(result["status"], "warning") self.assertTrue(all(not binary["available"] for binary in result["details"].values())) def test_port_reports_occupied_without_guessing_the_owner(self): with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) listener.listen() result = diagnostics.port_status("127.0.0.1", listener.getsockname()[1]) self.assertEqual(result["details"]["state"], "in_use") with patch.object(diagnostics.socket, "socket", side_effect=PermissionError(errno.EACCES, "secret")): result = diagnostics.port_status("127.0.0.1", 443) self.assertEqual(result["status"], "unknown") self.assertNotIn("secret", json.dumps(result)) def test_doctor_uses_source_tray_tls_and_standalone_configuration_endpoints(self): self.assertEqual(diagnostics.doctor_endpoint(self.config), ("127.0.0.1", 0)) tls = self.config.directory("secrets") / "tls" tls.mkdir(parents=True) for name in ("peach-local-ca.crt", "peach.crt", "peach.key"): (tls / name).write_text("fixture", encoding="utf-8") with patch.object(diagnostics.distribution, "standalone", return_value=False), \ patch.object(diagnostics.sys, "platform", "win32"), \ patch.dict(diagnostics.os.environ, {"PEACH_LAN_ADDRESS": "192.0.2.10"}): self.assertEqual(diagnostics.doctor_endpoint(self.config), ("192.0.2.10", 443)) with patch.object(diagnostics.distribution, "standalone", return_value=False), \ patch.object(diagnostics.sys, "platform", "darwin"): self.assertEqual(diagnostics.doctor_endpoint(self.config), ("0.0.0.0", 8443)) with patch.object(diagnostics.distribution, "standalone", return_value=True): self.assertEqual(diagnostics.doctor_endpoint(self.config), ("127.0.0.1", 0)) def test_mount_unknown_and_failure_do_not_claim_online_or_break_service_readiness(self): upgrade(self.db, ROOT / "migrations") for state, expected in (("checking", "unknown"), ("degraded", "warning")): snapshot = {"state": state, "sources": [{"state": "timeout", "online": None}], "warnings": ["本地磁盘:目录不存在"] if state == "degraded" else []} self.assertEqual(diagnostics.media_mounts(snapshot)["status"], expected) self.assertTrue(health.readiness(self.settings)["ready"]) def test_doctor_mount_probe_returns_unknown_at_the_deadline_without_waiting_for_io(self): from peach.mount_reachability import MountReachability, MountRoot release = threading.Event() entered = threading.Event() def stalled(_path): entered.set() release.wait(5) return "ok" monitor = MountReachability([MountRoot("local", "本地磁盘", "R:/private", self.root)], probe=stalled) try: with patch.object(diagnostics, "MountReachability", return_value=monitor), \ patch.object(diagnostics.time, "monotonic", side_effect=[0, 100]): snapshot = diagnostics.probe_mounts([]) self.assertTrue(entered.wait(1)) self.assertEqual(diagnostics.media_mounts(snapshot)["status"], "unknown") self.assertIsNone(snapshot["sources"][0]["online"]) finally: release.set() monitor.stop() def test_recent_failure_excludes_media_paths_tokens_and_raw_errors(self): upgrade(self.db, ROOT / "migrations") with closing(sqlite3.connect(self.db)) as connection, connection: connection.execute("INSERT INTO task_run(task_key,trigger,status,mutex_key,pid,host,error,finished_at) " "VALUES('private-task','manual','failed','x',1,'private-host',?,?)", ("R:/secret/video.mp4?cookie=private-token", "2026-10-03T03:00:00Z")) result = diagnostics.recent_failure(self.db) self.assertEqual(result["status"], "warning") self.assertEqual(result["target"], "/tasks") text = json.dumps(result) for private in ("R:/secret", "cookie", "private-token", "private-host", "private-task"): self.assertNotIn(private, text) def test_public_components_only_include_status_and_reason(self): binary = BinaryChoice(self.root / "tools/ffmpeg", "peach-managed") with patch.object(diagnostics.FFmpegResolver, "ffmpeg", return_value=binary), \ patch.object(diagnostics.FFmpegResolver, "ffprobe", return_value=binary), \ patch.object(diagnostics.settings_file, "active", return_value=self.config), \ patch.object(diagnostics, 'binary_diagnostics') as inspect: result = diagnostics.health_components(self.settings, EMPTY_MOUNTS) inspect.assert_not_called() self.assertEqual(set(result), {"database", "schema", "configured", "ffmpeg", "media_mounts", "security"}) self.assertTrue(all(set(value) == {"status", "reason"} for value in result.values())) self.assertNotIn(str(self.root), json.dumps(result)) def test_doctor_json_uses_the_shared_report_and_packaged_dispatch_finds_it(self): result = {"version": "test", "status": "warning", "checks": {}} output = io.StringIO() with patch.object(diagnostics, "report", return_value=result), \ patch.object(diagnostics, "probe_mounts", return_value=EMPTY_MOUNTS), \ patch.object(diagnostics.settings_file, "active", return_value=self.config), redirect_stdout(output): code = cli.main(["doctor", "--json", "--db", str(self.db)]) self.assertEqual(code, 0) self.assertEqual(json.loads(output.getvalue()), result) self.assertIn("doctor", cli.subcommands()) def test_doctor_text_reports_failure_and_exits_nonzero(self): result = {"version": "test", "status": "failed", "checks": { "database": diagnostics.item("数据库", "failed", "数据库不存在", action="检查本机数据库与备份")}} output = io.StringIO() with patch.object(diagnostics, "report", return_value=result), \ patch.object(diagnostics, "probe_mounts", return_value=EMPTY_MOUNTS), \ patch.object(diagnostics.settings_file, "active", return_value=self.config), redirect_stdout(output): code = cli.main(["doctor", "--db", str(self.db)]) self.assertEqual(code, 1) self.assertIn("数据库 [failed] 数据库不存在", output.getvalue()) self.assertIn("检查本机数据库与备份", output.getvalue())