"""Cloud destination, startup, recovery and status regressions; no real network/data.""" import json import tempfile import unittest from pathlib import Path from unittest import mock import archive_auto_backup as archive import backend_client as backend class ArchiveUploadFlowTest(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) self.source = self.root / "WXWork" self.message = self.source / "100" / "Data" / "message.db" self.message.parent.mkdir(parents=True) self.message.touch() self.config = archive.AutoBackupConfig(self.root, self.source, self.root / "archive_auto_backup", (backend.PACKAGED_SERVER_URL,)) self.decrypted = [(str(self.root / "message.db"), "message.db", "100")] for patcher in ( mock.patch.object(archive, "application_data_dir", return_value=self.root), mock.patch.object(archive, "_backup_policy", return_value=(True, 1)), mock.patch.dict(archive.os.environ, {"WECOM_ARCHIVE_AUTO_BACKUP": "1", "WECOM_ARCHIVE_API_URL": ""}), ): patcher.start() self.addCleanup(patcher.stop) def test_source_uses_configured_cloud_without_local_fallback(self): cloud = "http://archive.example.invalid:8369" with mock.patch.object(archive, "is_frozen", return_value=False), mock.patch.object(backend, "is_frozen", return_value=False), mock.patch.object(backend, "load_settings", return_value={"server_url": cloud}): self.assertEqual(archive._api_candidates(), [cloud]) def test_installed_build_ignores_stale_override(self): with mock.patch.object(archive, "is_frozen", return_value=True), mock.patch.object(backend, "is_frozen", return_value=True), mock.patch.dict(archive.os.environ, {"WECOM_ARCHIVE_API_URL": "http://old.example.invalid"}): self.assertEqual(archive._api_candidates("http://127.0.0.1:8766"), [backend.PACKAGED_SERVER_URL]) def test_source_retains_explicit_development_endpoint(self): with mock.patch.object(archive, "is_frozen", return_value=False): self.assertEqual(archive._api_candidates("http://localhost:8766/"), ["http://localhost:8766"]) def test_source_local_legacy_port_fallback(self): with mock.patch.object(archive, "is_frozen", return_value=False), mock.patch.object(backend, "is_frozen", return_value=False), mock.patch.object(backend, "load_settings", return_value={"server_url": "http://127.0.0.1:8765"}): self.assertEqual(archive._api_candidates(), ["http://127.0.0.1:8765", "http://127.0.0.1:8766"]) def test_desktop_keys_allow_upload_without_legacy_key_file(self): with mock.patch.object(archive, "_load_exporter_keys", side_effect=archive.ArchiveBackupError("missing")), mock.patch("wxwork_db.load_keys", return_value={"100": "11" * 16}), mock.patch("wxwork_db.decrypt_with_keys", return_value=self.decrypted) as decrypt, mock.patch("wxwork_local_setup.acquire_local_keys") as acquire: self.assertEqual(archive._prepare_archive_databases(self.config), self.decrypted) self.assertEqual(decrypt.call_args.args[2], {"100": "11" * 16}) acquire.assert_not_called() def test_missing_keys_are_acquired_once_then_decryption_retried(self): with mock.patch.object(archive, "_load_exporter_keys", return_value={}), mock.patch("wxwork_db.load_keys", side_effect=[{}, {"100": "22" * 16}]), mock.patch("wxwork_db.decrypt_with_keys", side_effect=[[], self.decrypted]) as decrypt, mock.patch("wxwork_local_setup.select_source_directory") as select, mock.patch("wxwork_local_setup.acquire_local_keys") as acquire: self.assertEqual(archive._prepare_archive_databases(self.config), self.decrypted) acquire.assert_called_once_with() select.assert_called_once_with(str(self.source)) self.assertEqual(decrypt.call_count, 2) def test_key_acquisition_failure_is_recorded_without_upload(self): with mock.patch.object(archive, "_configured_source_root", return_value=self.source), mock.patch.object(archive, "_load_exporter_modules", return_value=(None, None)), mock.patch.object(archive, "_load_exporter_keys", return_value={}), mock.patch("wxwork_db.load_keys", return_value={}), mock.patch("wxwork_db.decrypt_with_keys", return_value=[]), mock.patch("wxwork_local_setup.select_source_directory"), mock.patch("wxwork_local_setup.acquire_local_keys", side_effect=ValueError("please log in to WeCom")), mock.patch.object(archive.ArchiveApiClient, "connect") as connect: with self.assertRaisesRegex(archive.ArchiveBackupError, "please log in to WeCom"): archive.run_backup_once(self.config) connect.assert_not_called() self.assertEqual(archive.read_backup_status()["status"], "failed") self.assertFalse((self.config.work_root / "backup.lock").exists()) def test_cloud_failure_is_recorded_for_retry(self): with mock.patch.object(archive, "_configured_source_root", return_value=self.source), mock.patch.object(archive, "_load_exporter_modules", return_value=(None, None)), mock.patch.object(archive, "_prepare_archive_databases", return_value=self.decrypted), mock.patch.object(archive.ArchiveApiClient, "connect", side_effect=archive.ArchiveBackupError("HTTP 401")): with self.assertRaisesRegex(archive.ArchiveBackupError, "401"): archive.run_backup_once(self.config) self.assertIn("401", archive.read_backup_status()["message"]) self.assertFalse((self.config.work_root / "backup.lock").exists()) def run_scheduler(self, enabled): class Clock: now = 0 waits = 0 def is_set(self): return self.waits >= 4 def wait(self, seconds): self.now += seconds self.waits += 1 return self.is_set() clock = Clock() runs = [] with mock.patch.object(archive, "_STOP_EVENT", clock), mock.patch.object(archive.time, "monotonic", side_effect=lambda: clock.now), mock.patch.object(archive, "_refresh_cloud_policy"), mock.patch.object(archive, "_backup_policy", return_value=(enabled, 1)), mock.patch.object(archive, "_run_worker_with_retries", side_effect=lambda *args: runs.append(clock.now)): archive._launch_worker(self.config, 0) return runs def test_startup_uploads_immediately_then_respects_interval(self): self.assertEqual(self.run_scheduler(True), [0, 60]) def test_disabled_cloud_policy_never_launches_upload(self): self.assertEqual(self.run_scheduler(False), []) def save_status(self, payload): self.config.work_root.mkdir(exist_ok=True) (self.config.work_root / "status.json").write_text(json.dumps(payload), encoding="utf-8-sig") def test_status_handles_missing_and_malformed_file(self): self.assertEqual(archive.read_backup_status()["status"], "waiting") for payload in ([], {"status": "completed", "summary": "bad"}, {"status": "completed", "completed_at": "bad"}): self.save_status(payload) self.assertEqual(archive.read_backup_status()["status"], "waiting") def test_status_shows_success_and_running_without_raw_fields(self): for status in ("running", "completed"): self.save_status({"status": status, "completed_at": 1000, "summary": {"inserted": 23}, "source_root": "private-path"}) result = archive.read_backup_status() self.assertEqual(result["status"], status) self.assertEqual(set(result), {"status", "message"}) if status == "completed": self.assertIn("23", result["message"]) def test_status_removes_signed_storage_url_query(self): self.save_status({"status": "failed", "error": "upload failed: https://u:p@storage.example.invalid/file?q-signature=secret-signature&q-ak=secret-id"}) result = archive.read_backup_status()["message"] for secret in ("secret-signature", "secret-id", "u:p@"): self.assertNotIn(secret, result) self.assertIn("storage.example.invalid/file", result) def test_disabled_policy_overrides_old_success(self): self.save_status({"status": "completed", "summary": {"inserted": 1}}) with mock.patch.object(archive, "_backup_policy", return_value=(False, 1)): self.assertEqual(archive.read_backup_status()["status"], "disabled") def test_encrypted_database_to_cloud_client_and_checkpoint(self): from packaging_checks import check_database_initialization check_database_initialization() if __name__ == "__main__": unittest.main()