136 lines
8.6 KiB
Python
136 lines
8.6 KiB
Python
"""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()
|