feat: 增加认证更新配置与自动检查 (#94)
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
"""在线更新清单、下载校验和安全解压测试。"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from src.update_service import (
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
UnsafeUpdateArchiveError,
|
||||
UpdateConfigurationError,
|
||||
UpdateIntegrityError,
|
||||
UpdateCredentials,
|
||||
UpdateService,
|
||||
_SameOriginRedirectHandler,
|
||||
mark_current_version_healthy,
|
||||
parse_version,
|
||||
validate_manifest_url,
|
||||
@@ -41,9 +46,11 @@ class FakeUrlOpen:
|
||||
def __init__(self, responses):
|
||||
self.responses = responses
|
||||
self.requested_urls = []
|
||||
self.requested_headers = []
|
||||
|
||||
def __call__(self, request, timeout):
|
||||
self.requested_urls.append((request.full_url, timeout))
|
||||
self.requested_headers.append(dict(request.header_items()))
|
||||
content, final_url = self.responses[request.full_url]
|
||||
return FakeResponse(content, final_url, len(content))
|
||||
|
||||
@@ -107,6 +114,10 @@ class UpdateServiceTest(unittest.TestCase):
|
||||
validate_manifest_url(self.manifest_url),
|
||||
self.manifest_url,
|
||||
)
|
||||
self.assertEqual(
|
||||
validate_manifest_url(DEFAULT_UPDATE_MANIFEST_URL),
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
)
|
||||
for invalid in (
|
||||
"http://updates.example.test/manifest.json",
|
||||
"https://user:pass@updates.example.test/manifest.json",
|
||||
@@ -118,6 +129,56 @@ class UpdateServiceTest(unittest.TestCase):
|
||||
):
|
||||
validate_manifest_url(invalid)
|
||||
|
||||
def test_basic_auth_is_used_for_manifest_and_update_without_repr_leak(self):
|
||||
update_content = make_update_zip()
|
||||
service, opener = self.service_with(update_content)
|
||||
credentials = UpdateCredentials("release-reader", "unit-test-password")
|
||||
|
||||
result = service.check(
|
||||
self.manifest_url,
|
||||
"0.1.0",
|
||||
credentials=credentials,
|
||||
)
|
||||
service.download_and_stage(result.update, credentials=credentials)
|
||||
|
||||
expected = "Basic " + base64.b64encode(
|
||||
b"release-reader:unit-test-password"
|
||||
).decode("ascii")
|
||||
self.assertEqual(len(opener.requested_headers), 2)
|
||||
self.assertTrue(
|
||||
all(
|
||||
headers.get("Authorization") == expected
|
||||
for headers in opener.requested_headers
|
||||
)
|
||||
)
|
||||
self.assertNotIn("unit-test-password", repr(credentials))
|
||||
|
||||
def test_default_unicode_manifest_path_is_encoded_for_http_request(self):
|
||||
request = UpdateService._make_request(
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
UpdateCredentials("release-reader", "unit-test-password"),
|
||||
)
|
||||
|
||||
self.assertNotIn("——", request.full_url)
|
||||
self.assertIn("%E2%80%94%E2%80%94", request.full_url)
|
||||
|
||||
def test_authenticated_redirect_cannot_change_origin(self):
|
||||
handler = _SameOriginRedirectHandler()
|
||||
request = urllib.request.Request(
|
||||
"https://updates.example.test/manifest.json",
|
||||
headers={"Authorization": "Basic test"},
|
||||
)
|
||||
|
||||
with self.assertRaises(UpdateConfigurationError):
|
||||
handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://other.example.test/manifest.json",
|
||||
)
|
||||
|
||||
def test_check_reports_newer_and_current_versions(self):
|
||||
update_content = make_update_zip()
|
||||
service, _opener = self.service_with(update_content)
|
||||
@@ -196,7 +257,7 @@ class UpdateServiceTest(unittest.TestCase):
|
||||
health = json.loads(
|
||||
(self.update_directory / "healthy.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(health["version"], "0.2.0")
|
||||
self.assertEqual(health["version"], "0.2.1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""设置页在线更新 UI 和后台线程测试。"""
|
||||
"""设置页认证更新配置、自动检查和后台线程测试。"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
@@ -11,38 +11,72 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
from PyQt5.QtTest import QTest
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.settings_repository import SettingsRepository
|
||||
from src.settings_ui import SettingsPage
|
||||
from src.update_service import (
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
DEFAULT_UPDATE_USERNAME,
|
||||
UPDATE_MANIFEST_SETTING,
|
||||
UPDATE_USERNAME_SETTING,
|
||||
UpdateCheckResult,
|
||||
UpdateInfo,
|
||||
)
|
||||
|
||||
|
||||
MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json"
|
||||
TEST_USERNAME = "release-reader"
|
||||
TEST_PASSWORD = "unit-test-password"
|
||||
|
||||
|
||||
class FakeCredentialStore:
|
||||
def __init__(self, saved=None):
|
||||
self.saved = saved
|
||||
self.save_count = 0
|
||||
|
||||
def read(self):
|
||||
return self.saved
|
||||
|
||||
def save(self, username, password):
|
||||
self.saved = (username, password)
|
||||
self.save_count += 1
|
||||
|
||||
|
||||
class FakeUpdateService:
|
||||
def __init__(self, result=None, delay=0.0, download_error=None):
|
||||
self.result = result or UpdateCheckResult("0.2.0", "0.2.0", False)
|
||||
self.result = result or UpdateCheckResult("0.2.1", "0.2.1", False)
|
||||
self.delay = delay
|
||||
self.download_error = download_error
|
||||
self.check_count = 0
|
||||
self.download_count = 0
|
||||
self.check_credentials = None
|
||||
self.download_credentials = None
|
||||
|
||||
def check(self, manifest_url, current_version="0.1.0", is_cancelled=None):
|
||||
def check(
|
||||
self,
|
||||
manifest_url,
|
||||
current_version="0.2.1",
|
||||
is_cancelled=None,
|
||||
credentials=None,
|
||||
):
|
||||
self.check_count += 1
|
||||
self.manifest_url = manifest_url
|
||||
self.check_credentials = credentials
|
||||
if self.delay:
|
||||
time.sleep(self.delay)
|
||||
return self.result
|
||||
|
||||
def download_and_stage(self, update, is_cancelled=None, on_progress=None):
|
||||
def download_and_stage(
|
||||
self,
|
||||
update,
|
||||
is_cancelled=None,
|
||||
on_progress=None,
|
||||
credentials=None,
|
||||
):
|
||||
self.download_count += 1
|
||||
self.download_credentials = credentials
|
||||
if on_progress is not None:
|
||||
on_progress(50)
|
||||
if self.download_error is not None:
|
||||
@@ -76,18 +110,19 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.repository = SettingsRepository(
|
||||
Path(self.temporary_directory.name) / "client.db"
|
||||
)
|
||||
self.db_path = Path(self.temporary_directory.name) / "client.db"
|
||||
self.repository = SettingsRepository(self.db_path)
|
||||
self.credential_store = FakeCredentialStore()
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def _page(self, service):
|
||||
def _page(self, service, credential_store=None):
|
||||
return SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
update_service=service,
|
||||
update_credential_store=credential_store or self.credential_store,
|
||||
)
|
||||
|
||||
def _wait_until(self, predicate, timeout_ms=2000):
|
||||
@@ -97,24 +132,86 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
elapsed += 10
|
||||
self.assertTrue(predicate(), "等待在线更新线程超时")
|
||||
|
||||
def test_update_card_loads_saved_url_and_current_version(self):
|
||||
self.repository.set(UPDATE_MANIFEST_SETTING, MANIFEST_URL)
|
||||
def _save_configuration(self, page):
|
||||
page.updateManifestUrlInput.setText(MANIFEST_URL)
|
||||
page.updateUsernameInput.setText(TEST_USERNAME)
|
||||
page.updatePasswordInput.setText(TEST_PASSWORD)
|
||||
page.updateSaveButton.click()
|
||||
|
||||
def test_update_card_uses_safe_defaults_and_password_input(self):
|
||||
page = self._page(FakeUpdateService())
|
||||
|
||||
self.assertEqual(page.currentVersionLabel.text(), "0.2.0")
|
||||
self.assertEqual(page.updateManifestUrlInput.text(), MANIFEST_URL)
|
||||
self.assertTrue(page.updateCheckButton.isEnabled())
|
||||
self.assertEqual(page.currentVersionLabel.text(), "0.2.1")
|
||||
self.assertEqual(
|
||||
page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL
|
||||
)
|
||||
self.assertEqual(page.updateUsernameInput.text(), DEFAULT_UPDATE_USERNAME)
|
||||
self.assertEqual(page.updatePasswordInput.text(), "")
|
||||
self.assertEqual(page.updatePasswordInput.echoMode(), QLineEdit.Password)
|
||||
self.assertTrue(page.updateSaveButton.isEnabled())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_http_url_is_rejected_before_starting_worker(self):
|
||||
def test_save_uses_sqlite_for_non_secret_and_credential_store_for_password(self):
|
||||
page = self._page(FakeUpdateService())
|
||||
|
||||
self._save_configuration(page)
|
||||
|
||||
self.assertEqual(
|
||||
self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL
|
||||
)
|
||||
self.assertEqual(self.repository.get(UPDATE_USERNAME_SETTING), TEST_USERNAME)
|
||||
self.assertEqual(
|
||||
self.credential_store.saved,
|
||||
(TEST_USERNAME, TEST_PASSWORD),
|
||||
)
|
||||
self.assertNotIn(TEST_PASSWORD.encode(), self.db_path.read_bytes())
|
||||
self.assertEqual(page.updatePasswordInput.text(), "")
|
||||
self.assertIn("已安全保存", page.updatePasswordInput.placeholderText())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_blank_password_preserves_existing_credential(self):
|
||||
self.repository.set_many(
|
||||
{
|
||||
UPDATE_MANIFEST_SETTING: MANIFEST_URL,
|
||||
UPDATE_USERNAME_SETTING: TEST_USERNAME,
|
||||
}
|
||||
)
|
||||
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
|
||||
page = self._page(FakeUpdateService(), store)
|
||||
|
||||
page.updateSaveButton.click()
|
||||
|
||||
self.assertEqual(store.save_count, 0)
|
||||
self.assertEqual(store.saved, (TEST_USERNAME, TEST_PASSWORD))
|
||||
self.assertIn("安全保存", page.updateStatusLabel.text())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_missing_password_stops_save_and_check(self):
|
||||
service = FakeUpdateService()
|
||||
page = self._page(service)
|
||||
page.updateManifestUrlInput.setText("http://updates.example.test/manifest.json")
|
||||
|
||||
page.updateSaveButton.click()
|
||||
page.updateCheckButton.click()
|
||||
|
||||
self.assertIn("必须是有效的 HTTPS", page.updateStatusLabel.text())
|
||||
self.assertIn("密码", page.updateStatusLabel.text())
|
||||
self.assertEqual(service.check_count, 0)
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_unapproved_http_url_is_rejected_when_saving(self):
|
||||
service = FakeUpdateService()
|
||||
page = self._page(service)
|
||||
page.updateManifestUrlInput.setText(
|
||||
"http://updates.example.test/manifest.json"
|
||||
)
|
||||
page.updatePasswordInput.setText(TEST_PASSWORD)
|
||||
|
||||
page.updateSaveButton.click()
|
||||
|
||||
self.assertIn("HTTP 只允许固定发布服务器", page.updateStatusLabel.text())
|
||||
self.assertEqual(service.check_count, 0)
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
@@ -122,7 +219,7 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
def test_slow_check_does_not_block_or_start_twice(self):
|
||||
service = FakeUpdateService(delay=0.08)
|
||||
page = self._page(service)
|
||||
page.updateManifestUrlInput.setText(MANIFEST_URL)
|
||||
self._save_configuration(page)
|
||||
timer_fired = []
|
||||
QTimer.singleShot(10, lambda: timer_fired.append(True))
|
||||
|
||||
@@ -134,27 +231,46 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(service.check_count, 1)
|
||||
self.assertEqual(
|
||||
self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL
|
||||
)
|
||||
self.assertEqual(service.check_credentials.username, TEST_USERNAME)
|
||||
self.assertEqual(service.check_credentials.password, TEST_PASSWORD)
|
||||
self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_confirmed_new_version_downloads_and_stages(self):
|
||||
def test_saved_configuration_checks_automatically_after_startup(self):
|
||||
self.repository.set_many(
|
||||
{
|
||||
UPDATE_MANIFEST_SETTING: MANIFEST_URL,
|
||||
UPDATE_USERNAME_SETTING: TEST_USERNAME,
|
||||
}
|
||||
)
|
||||
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
|
||||
service = FakeUpdateService()
|
||||
|
||||
page = self._page(service, store)
|
||||
self._wait_until(lambda: service.check_count == 1)
|
||||
self._wait_until(
|
||||
lambda: page.eventBinder.updateEventBinder._check_thread is None
|
||||
)
|
||||
|
||||
self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_confirmed_new_version_downloads_with_saved_credentials(self):
|
||||
update = UpdateInfo(
|
||||
version="0.2.0",
|
||||
version="0.3.0",
|
||||
manifest_url=MANIFEST_URL,
|
||||
update_url="https://updates.example.test/CMAutoBuy-0.2.0-update.zip",
|
||||
file_name="CMAutoBuy-0.2.0-update.zip",
|
||||
update_url="https://updates.example.test/CMAutoBuy-0.3.0-update.zip",
|
||||
file_name="CMAutoBuy-0.3.0-update.zip",
|
||||
size=1024,
|
||||
sha256="0" * 64,
|
||||
)
|
||||
service = FakeUpdateService(
|
||||
UpdateCheckResult("0.1.0", "0.2.0", True, update)
|
||||
UpdateCheckResult("0.2.1", "0.3.0", True, update)
|
||||
)
|
||||
page = self._page(service)
|
||||
page.updateManifestUrlInput.setText(MANIFEST_URL)
|
||||
self._save_configuration(page)
|
||||
|
||||
with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox):
|
||||
page.updateCheckButton.click()
|
||||
@@ -164,8 +280,9 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(service.download_count, 1)
|
||||
self.assertEqual(service.download_credentials.username, TEST_USERNAME)
|
||||
self.assertEqual(service.download_credentials.password, TEST_PASSWORD)
|
||||
self.assertIn("已准备好", page.updateStatusLabel.text())
|
||||
self.assertIn("重新启动", page.updateStatusLabel.text())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Windows 更新凭据按服务器隔离的测试。"""
|
||||
|
||||
import unittest
|
||||
|
||||
from src.windows_credential_store import WindowsCredentialStore
|
||||
|
||||
|
||||
class WindowsCredentialStoreTest(unittest.TestCase):
|
||||
def test_different_origins_use_different_credential_targets(self):
|
||||
http_store = WindowsCredentialStore.for_url(
|
||||
"http://cm.xiapi.com/autobuy——manifest.json"
|
||||
)
|
||||
https_store = WindowsCredentialStore.for_url(
|
||||
"https://cm.xiapi.com/autobuy——manifest.json"
|
||||
)
|
||||
other_store = WindowsCredentialStore.for_url(
|
||||
"https://updates.example.test/manifest.json"
|
||||
)
|
||||
|
||||
self.assertNotEqual(http_store.target, https_store.target)
|
||||
self.assertNotEqual(https_store.target, other_store.target)
|
||||
self.assertNotIn("@", http_store.target)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user