mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-21 02:58:45 +00:00
Updates refactoring
This commit is contained in:
1 parent
c317406b52
commit
03be9baad1
10 files changed
+752
-563
No files matched your search
+138
-13
@@ -1,12 +1,18 @@
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from http.client import IncompleteRead
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from utils import linux_update
|
||||
from utils import linux_update, update_check
|
||||
|
||||
|
||||
class _Proc:
|
||||
@@ -97,7 +103,7 @@ class InstallCmdTest(unittest.TestCase):
|
||||
|
||||
def test_rpm_falls_back_to_rpm_binary(self):
|
||||
self.assertEqual(
|
||||
self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', '--force', str(self.package_path)],
|
||||
self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', str(self.package_path)],
|
||||
)
|
||||
|
||||
def test_unknown_kind_is_rejected(self):
|
||||
@@ -127,7 +133,7 @@ class ApplyUpdateTest(unittest.TestCase):
|
||||
exe.write_bytes(b'old')
|
||||
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch('utils.update_check.download_asset', self._download()):
|
||||
mock.patch('utils.linux_update.download_asset', self._download()):
|
||||
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||
|
||||
self.assertEqual(exe.read_bytes(), b'new')
|
||||
@@ -139,7 +145,7 @@ class ApplyUpdateTest(unittest.TestCase):
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch('utils.update_check.download_asset', self._download()):
|
||||
mock.patch('utils.linux_update.download_asset', self._download()):
|
||||
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
|
||||
|
||||
@@ -155,18 +161,18 @@ class ApplyUpdateTest(unittest.TestCase):
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch.object(linux_update.os, 'access', return_value=False), \
|
||||
mock.patch.object(linux_update, '_is_root', return_value=False), \
|
||||
mock.patch('utils.update_check.download_asset', _download), \
|
||||
mock.patch('utils.linux_update.download_asset', _download), \
|
||||
mock.patch.object(linux_update.subprocess, 'run', run):
|
||||
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||
|
||||
cmd = run.call_args[0][0]
|
||||
self.assertEqual(cmd[:4], ['pkexec', 'install', '-m', '755'])
|
||||
self.assertEqual(cmd[:3], ['pkexec', 'sh', '-c'])
|
||||
self.assertEqual(cmd[-1], str(exe))
|
||||
|
||||
def test_deb_install_failure_returns_stderr(self):
|
||||
run = mock.Mock(return_value=_Proc(1, stderr=b'dpkg: error'))
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
|
||||
mock.patch('utils.update_check.download_asset', self._download()), \
|
||||
mock.patch('utils.linux_update.download_asset', self._download()), \
|
||||
mock.patch.object(linux_update.subprocess, 'run', run), \
|
||||
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||
mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
@@ -184,7 +190,7 @@ class ApplyUpdateTest(unittest.TestCase):
|
||||
return path
|
||||
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
|
||||
mock.patch('utils.update_check.download_asset', _download), \
|
||||
mock.patch('utils.linux_update.download_asset', _download), \
|
||||
mock.patch.object(linux_update.subprocess, 'run',
|
||||
return_value=_Proc(0)), \
|
||||
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||
@@ -206,16 +212,99 @@ class ApplyUpdateTest(unittest.TestCase):
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch('utils.update_check.download_asset', _download):
|
||||
mock.patch('utils.linux_update.download_asset', _download):
|
||||
linux_update.apply_update('binary', 'https://x.invalid/a', digest='sha256:ab')
|
||||
|
||||
self.assertEqual(seen['digest'], 'sha256:ab')
|
||||
|
||||
def test_download_failure_is_reported(self):
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \
|
||||
mock.patch('utils.update_check.download_asset',
|
||||
side_effect=OSError('boom')):
|
||||
self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), 'boom')
|
||||
for error in (OSError('boom'), ValueError('invalid checksum'), IncompleteRead(b'partial', 100)):
|
||||
with self.subTest(error=error), \
|
||||
mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \
|
||||
mock.patch.object(linux_update, 'download_asset', side_effect=error), \
|
||||
mock.patch.object(linux_update, '_run_install') as install:
|
||||
self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), str(error))
|
||||
install.assert_not_called()
|
||||
|
||||
def test_corrupt_download_never_changes_binary_or_runs_a_package_manager(self):
|
||||
for kind in ('binary', 'deb', 'rpm'):
|
||||
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
opener = mock.Mock()
|
||||
opener.open.return_value = io.BytesIO(b'corrupt')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch.object(update_check, 'build_github_opener', return_value=opener), \
|
||||
mock.patch.object(linux_update, '_run_install') as install:
|
||||
error = linux_update.apply_update(kind, 'https://example.invalid/asset',
|
||||
digest='sha256:' + '0' * 64)
|
||||
self.assertIn('SHA-256', error)
|
||||
install.assert_not_called()
|
||||
self.assertEqual(exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||
|
||||
def test_verified_download_is_installed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
opener = mock.Mock()
|
||||
opener.open.return_value = io.BytesIO(b'new')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch.object(update_check, 'build_github_opener', return_value=opener):
|
||||
self.assertIsNone(linux_update.apply_update(
|
||||
'binary', 'https://example.invalid/asset',
|
||||
digest='sha256:' + hashlib.sha256(b'new').hexdigest(),
|
||||
))
|
||||
self.assertEqual(exe.read_bytes(), b'new')
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||
|
||||
def test_install_timeout_cleans_download_directory(self):
|
||||
holder = {}
|
||||
|
||||
def download(url, dest_dir, **kwargs):
|
||||
holder['directory'] = dest_dir
|
||||
path = dest_dir / 'update.deb'
|
||||
path.write_bytes(b'package')
|
||||
return path
|
||||
|
||||
with mock.patch.object(linux_update, 'download_asset', side_effect=download), \
|
||||
mock.patch.object(linux_update, '_run_install', side_effect=subprocess.TimeoutExpired('apt-get', 600)):
|
||||
self.assertIsNotNone(linux_update.apply_update('deb', 'https://example.invalid/asset'))
|
||||
self.assertFalse(holder['directory'].exists())
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux install(1) and executable replacement')
|
||||
class ElevatedBinaryReplacementTest(unittest.TestCase):
|
||||
def test_replaces_a_running_executable_with_special_characters_in_its_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
exe = directory / 'app $(touch unwanted); тест'
|
||||
src = directory / 'download with spaces'
|
||||
shutil.copy2(shutil.which('sleep'), exe)
|
||||
src.write_bytes(b'new')
|
||||
process = subprocess.Popen([str(exe), '30'])
|
||||
try:
|
||||
with mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
cmd = linux_update.replace_binary_cmd(src, exe)
|
||||
self.assertEqual(subprocess.run(cmd, cwd=tmp, timeout=10).returncode, 0)
|
||||
self.assertIsNone(process.poll())
|
||||
self.assertEqual(exe.read_bytes(), b'new')
|
||||
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
|
||||
self.assertEqual(set(directory.iterdir()), {exe, src})
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
|
||||
def test_failed_copy_preserves_the_old_binary_and_removes_the_stage(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'app'
|
||||
exe.write_bytes(b'old')
|
||||
with mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
cmd = linux_update.replace_binary_cmd(Path(tmp) / 'missing', exe)
|
||||
result = subprocess.run(cmd, stderr=subprocess.PIPE, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||
|
||||
|
||||
class ExternalCommandsEnvTest(unittest.TestCase):
|
||||
@@ -276,5 +365,41 @@ class RelaunchEnvTest(unittest.TestCase):
|
||||
self.assertEqual(out, env)
|
||||
|
||||
|
||||
class RestartAfterUpdateTest(unittest.TestCase):
|
||||
def test_restart_releases_resources_before_launch_and_preserves_arguments(self):
|
||||
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
|
||||
import linux
|
||||
|
||||
events = []
|
||||
exe = str(Path(tempfile.gettempdir()) / 'proxy')
|
||||
with mock.patch.object(linux, 'stop_proxy', side_effect=lambda: events.append('stop')), \
|
||||
mock.patch.object(linux, 'release_lock', side_effect=lambda: events.append('unlock')), \
|
||||
mock.patch.object(linux_update, 'exe_path', return_value=Path(exe)), \
|
||||
mock.patch.object(linux, 'relaunch_env', return_value={'PATH': '/usr/bin'}), \
|
||||
mock.patch.object(sys, 'argv', ['proxy', '--portable']), \
|
||||
mock.patch.object(linux.subprocess, 'Popen', side_effect=lambda *a, **k: events.append('launch')) as launch, \
|
||||
mock.patch.object(linux.os, '_exit', side_effect=lambda code: events.append('exit')):
|
||||
linux._restart_after_update()
|
||||
self.assertEqual(events, ['stop', 'unlock', 'launch', 'exit'])
|
||||
self.assertEqual(launch.call_args[0][0], [exe, '--portable'])
|
||||
self.assertEqual(launch.call_args[1]['env'], {'PATH': '/usr/bin'})
|
||||
|
||||
def test_launch_failure_recovers_the_running_proxy_instead_of_exiting(self):
|
||||
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
|
||||
import linux
|
||||
|
||||
with ExitStack() as stack:
|
||||
mocks = {name: stack.enter_context(mock.patch.object(linux, name)) for name in (
|
||||
'stop_proxy', 'release_lock', 'acquire_lock', 'start_proxy', '_show_error',
|
||||
)}
|
||||
exit_process = stack.enter_context(mock.patch.object(linux.os, '_exit'))
|
||||
stack.enter_context(mock.patch.object(linux.subprocess, 'Popen', side_effect=OSError('launch failed')))
|
||||
linux._restart_after_update()
|
||||
mocks['acquire_lock'].assert_called_once_with()
|
||||
mocks['start_proxy'].assert_called_once_with(linux._config, mocks['_show_error'])
|
||||
mocks['_show_error'].assert_called_once()
|
||||
exit_process.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+181
-7
@@ -1,13 +1,22 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import IncompleteRead
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from utils import update_check
|
||||
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
|
||||
|
||||
PAYLOAD = b'payload'
|
||||
DIGEST = 'sha256:' + hashlib.sha256(PAYLOAD).hexdigest()
|
||||
|
||||
|
||||
class ParseVersionTupleTest(unittest.TestCase):
|
||||
def test_plain_and_prefixed_versions(self):
|
||||
@@ -96,7 +105,7 @@ def _opener(response):
|
||||
|
||||
|
||||
class DownloadAssetTest(unittest.TestCase):
|
||||
def _download(self, response, digest=''):
|
||||
def _download(self, response, digest=DIGEST):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
|
||||
path = update_check.download_asset(
|
||||
@@ -117,17 +126,22 @@ class DownloadAssetTest(unittest.TestCase):
|
||||
body, _ = self._download(_Response(b'payload'), digest=digest)
|
||||
self.assertEqual(body, b'payload')
|
||||
|
||||
def test_unknown_digest_format_is_ignored(self):
|
||||
body, _ = self._download(_Response(b'payload'), digest='md5:whatever')
|
||||
self.assertEqual(body, b'payload')
|
||||
def test_invalid_or_missing_digest_is_rejected_before_any_download(self):
|
||||
for digest in ('', None, 123, 'md5:whatever', 'sha256:ab', 'sha256:' + 'z' * 64):
|
||||
with self.subTest(digest=digest), tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(update_check, 'build_github_opener') as opener:
|
||||
with self.assertRaises(ValueError):
|
||||
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=digest)
|
||||
opener.assert_not_called()
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_truncated_download_raises_and_leaves_no_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
resp = _Response(b'half', content_length=99)
|
||||
resp = _Response(b'pay', content_length=None)
|
||||
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
|
||||
with self.assertRaises(OSError):
|
||||
update_check.download_asset(
|
||||
'https://example.invalid/a', Path(tmp), suffix='.bin',
|
||||
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=DIGEST,
|
||||
)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
@@ -146,10 +160,41 @@ class DownloadAssetTest(unittest.TestCase):
|
||||
body, _ = self._download(_Response(b'payload', content_length=None))
|
||||
self.assertEqual(body, b'payload')
|
||||
|
||||
def test_same_length_corruption_is_rejected(self):
|
||||
with self.assertRaisesRegex(OSError, 'SHA-256'):
|
||||
self._download(_Response(b'payloae'))
|
||||
|
||||
def test_uppercase_checksum_is_accepted(self):
|
||||
body, _ = self._download(_Response(PAYLOAD), digest=DIGEST.upper())
|
||||
self.assertEqual(body, PAYLOAD)
|
||||
|
||||
def test_stream_failure_removes_partial_file(self):
|
||||
for error in (TimeoutError('read timed out'), IncompleteRead(b'pay', 4)):
|
||||
with self.subTest(error=error), tempfile.TemporaryDirectory() as tmp:
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.side_effect = [b'pay', error]
|
||||
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
|
||||
with self.assertRaises(type(error)):
|
||||
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=DIGEST)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_hash_covers_every_chunk_and_read_timeout_is_set(self):
|
||||
body = PAYLOAD * 20000
|
||||
opener = _opener(_Response(body))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(update_check, 'build_github_opener', opener):
|
||||
path = update_check.download_asset(
|
||||
'https://example.invalid/a', Path(tmp),
|
||||
digest='sha256:' + hashlib.sha256(body).hexdigest(), timeout=7,
|
||||
)
|
||||
self.assertEqual(path.read_bytes(), body)
|
||||
opener.return_value.open.assert_called_once_with('https://example.invalid/a', timeout=7)
|
||||
|
||||
|
||||
class FindAssetTest(unittest.TestCase):
|
||||
def test_returns_asset_with_digest(self):
|
||||
assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': 'sha256:ab'}]
|
||||
assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': DIGEST}]
|
||||
with mock.patch.dict(update_check._state, {'assets': assets}):
|
||||
self.assertEqual(update_check.find_asset('a.deb'), assets[0])
|
||||
self.assertIsNone(update_check.find_asset('b.deb'))
|
||||
@@ -158,6 +203,135 @@ class FindAssetTest(unittest.TestCase):
|
||||
with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}):
|
||||
self.assertIsNone(update_check.find_asset('a.deb'))
|
||||
|
||||
def test_assets_without_a_valid_checksum_require_manual_installation(self):
|
||||
for digest in ('', 'sha256:ab', 'md5:' + '0' * 32):
|
||||
asset = {'name': 'a.exe', 'url': 'https://example.invalid/a.exe', 'digest': digest}
|
||||
with self.subTest(digest=digest), mock.patch.dict(update_check._state, {'assets': [asset]}):
|
||||
self.assertIsNone(update_check.find_asset('a.exe'))
|
||||
|
||||
|
||||
class RunCheckTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.patch = mock.patch.object(update_check, '_state', update_check.get_status())
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def _check(self, data, current='1.11.1'):
|
||||
with mock.patch.object(update_check, 'fetch_latest_release', return_value=data):
|
||||
update_check.run_check(current)
|
||||
return update_check.get_status()
|
||||
|
||||
def test_versions_are_compared_and_assets_published_together(self):
|
||||
release = {'tag_name': ' v2.0 ', 'assets': [{
|
||||
'name': 'app.exe', 'browser_download_url': 'https://example.invalid/app.exe', 'digest': DIGEST,
|
||||
}]}
|
||||
for current, has_update, ahead in (('1.11.1', True, False), ('2.0.0', False, False), ('2.1', False, True)):
|
||||
with self.subTest(current=current):
|
||||
state = self._check(release, current)
|
||||
self.assertTrue(state['checked'])
|
||||
self.assertIsNone(state['error'])
|
||||
self.assertEqual((state['has_update'], state['ahead_of_release']), (has_update, ahead))
|
||||
self.assertEqual(state['latest'], '2.0')
|
||||
self.assertEqual(state['html_url'], update_check.RELEASES_PAGE_URL)
|
||||
self.assertEqual(state['assets'][0]['digest'], DIGEST)
|
||||
|
||||
def test_invalid_response_is_an_error_not_a_claim_that_the_app_is_current(self):
|
||||
for data in (None, [], {}, {'tag_name': None}, {'tag_name': ''}, {'tag_name': 'oops'},
|
||||
{'tag_name': 'v2.0', 'assets': 'invalid'}):
|
||||
with self.subTest(data=data):
|
||||
state = self._check(data)
|
||||
self.assertTrue(state['error'])
|
||||
self.assertFalse(state['has_update'])
|
||||
self.assertIsNone(state['latest'])
|
||||
self.assertEqual(state['assets'], [])
|
||||
|
||||
def test_release_tag_suffixes_keep_the_existing_version_comparison(self):
|
||||
state = self._check({'tag_name': 'v1.12.0-hotfix'})
|
||||
self.assertIsNone(state['error'])
|
||||
self.assertTrue(state['has_update'])
|
||||
self.assertEqual(state['latest'], '1.12.0-hotfix')
|
||||
|
||||
def test_network_failure_clears_old_assets_and_does_not_prevent_retry(self):
|
||||
errors = (URLError('offline'), HTTPError('https://example.invalid', 403, 'rate limit', {}, None),
|
||||
TimeoutError(), IncompleteRead(b'{', 5), ValueError('bad json'))
|
||||
for error in errors:
|
||||
with self.subTest(error=error):
|
||||
self._check({'tag_name': 'v2.0', 'assets': [{
|
||||
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
|
||||
}]})
|
||||
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=error):
|
||||
update_check.run_check('1.11.1')
|
||||
state = update_check.get_status()
|
||||
self.assertTrue(state['error'])
|
||||
self.assertEqual(state['assets'], [])
|
||||
self.assertFalse(state['has_update'])
|
||||
self.assertIsNone(self._check({'tag_name': 'v2.1'})['error'])
|
||||
|
||||
def test_readers_do_not_see_partially_updated_status(self):
|
||||
previous = self._check({'tag_name': 'v1.11.1'})
|
||||
started, finish = threading.Event(), threading.Event()
|
||||
|
||||
def fetch():
|
||||
started.set()
|
||||
finish.wait(timeout=5)
|
||||
return {'tag_name': 'v2.0'}
|
||||
|
||||
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=fetch):
|
||||
worker = threading.Thread(target=update_check.run_check, args=('1.11.1',))
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
self.assertEqual(update_check.get_status(), previous)
|
||||
finally:
|
||||
finish.set()
|
||||
worker.join(timeout=5)
|
||||
self.assertEqual(update_check.get_status()['latest'], '2.0')
|
||||
|
||||
def test_asset_and_status_snapshots_cannot_modify_internal_state(self):
|
||||
self._check({'tag_name': 'v2.0', 'assets': [{
|
||||
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
|
||||
}]})
|
||||
asset = update_check.find_asset('a.exe')
|
||||
asset['digest'] = 'bad'
|
||||
snapshot = update_check.get_status()
|
||||
snapshot['assets'][0]['digest'] = 'bad'
|
||||
snapshot['assets'].clear()
|
||||
self.assertEqual(update_check.find_asset('a.exe')['digest'], DIGEST)
|
||||
|
||||
def test_old_cache_is_ignored_and_no_new_cache_is_written(self):
|
||||
from utils import tray_common
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / '.update_check_cache.json'
|
||||
old = '{"last_attempt_at": "invalid", "assets": "invalid"}'
|
||||
path.write_text(old, encoding='utf-8')
|
||||
with mock.patch.object(tray_common, 'APP_DIR', Path(tmp)):
|
||||
self.assertTrue(self._check({'tag_name': 'v2.0'})['has_update'])
|
||||
self.assertEqual(path.read_text(encoding='utf-8'), old)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [path])
|
||||
|
||||
def test_fetch_is_one_request_without_etag_and_with_timeout(self):
|
||||
opener = _opener(_Response(json.dumps({'tag_name': 'v2.0'}).encode()))
|
||||
with mock.patch.object(update_check, 'build_github_opener', opener):
|
||||
self.assertEqual(update_check.fetch_latest_release(timeout=7)['tag_name'], 'v2.0')
|
||||
args, kwargs = opener.return_value.open.call_args
|
||||
self.assertEqual(args[0].full_url, update_check.RELEASES_LATEST_API)
|
||||
self.assertFalse(args[0].has_header('If-none-match'))
|
||||
self.assertEqual(kwargs['timeout'], 7)
|
||||
|
||||
|
||||
class RelaunchEnvTest(unittest.TestCase):
|
||||
def test_old_and_new_bootloader_variables_and_bundle_subdirectories_are_removed(self):
|
||||
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
|
||||
env = {
|
||||
'_PYI_APPLICATION_HOME_DIR': bundle, '_PYI_ARCHIVE_FILE': 'old.exe',
|
||||
'_MEIPASS': bundle, '_MEIPASS2': bundle,
|
||||
'PATH': os.pathsep.join((bundle, bundle + os.sep + 'lib', bundle + '-other')),
|
||||
}
|
||||
with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, '_MEIPASS', bundle, create=True):
|
||||
result = update_check.relaunch_env()
|
||||
self.assertEqual(result, {'PATH': bundle + '-other', 'PYINSTALLER_RESET_ENVIRONMENT': '1'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
import hashlib
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from utils import update_check, windows_update
|
||||
|
||||
|
||||
class AssetNameTest(unittest.TestCase):
|
||||
def test_keeps_the_running_build_even_when_renamed_or_emulated(self):
|
||||
builds = (
|
||||
('win-amd64', (3, 11), 'TgWsProxy_windows.exe'),
|
||||
('win-amd64', (3, 8), 'TgWsProxy_windows_7_64bit.exe'),
|
||||
('win32', (3, 8), 'TgWsProxy_windows_7_32bit.exe'),
|
||||
('win-arm64', (3, 11), 'TgWsProxy_windows_arm64.exe'),
|
||||
('unknown', (3, 11), None),
|
||||
)
|
||||
for platform, version, expected in builds:
|
||||
with self.subTest(platform=platform, version=version), \
|
||||
mock.patch.object(windows_update.sysconfig, 'get_platform', return_value=platform), \
|
||||
mock.patch.object(sys, 'version_info', version), \
|
||||
mock.patch.object(sys, 'executable', 'renamed.exe'):
|
||||
self.assertEqual(windows_update.asset_name(), expected)
|
||||
|
||||
|
||||
class ApplyUpdateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
stack = ExitStack()
|
||||
self.addCleanup(stack.close)
|
||||
directory = stack.enter_context(tempfile.TemporaryDirectory())
|
||||
self.exe = Path(directory) / 'renamed app.exe'
|
||||
self.exe.write_bytes(b'old')
|
||||
self.backup = self.exe.with_name('renamed app_oldtgws.exe')
|
||||
self.opener = mock.Mock()
|
||||
self.opener.open.return_value = io.BytesIO(b'new')
|
||||
self.asset = {'url': 'https://example.invalid/new.exe',
|
||||
'digest': 'sha256:' + hashlib.sha256(b'new').hexdigest()}
|
||||
stack.enter_context(mock.patch.object(sys, 'executable', str(self.exe)))
|
||||
stack.enter_context(mock.patch.object(sys, 'argv', [str(self.exe), '--portable']))
|
||||
stack.enter_context(mock.patch.object(sys, '_MEIPASS', None, create=True))
|
||||
stack.enter_context(mock.patch.object(update_check, 'build_github_opener', return_value=self.opener))
|
||||
stack.enter_context(mock.patch.object(windows_update.subprocess, 'DETACHED_PROCESS', 8, create=True))
|
||||
stack.enter_context(mock.patch.object(windows_update.subprocess, 'CREATE_NEW_PROCESS_GROUP', 512, create=True))
|
||||
self.launch = stack.enter_context(mock.patch.object(windows_update.subprocess, 'Popen'))
|
||||
self.prepare = mock.Mock()
|
||||
|
||||
def test_verified_update_keeps_legacy_backup_and_launch_arguments(self):
|
||||
def prepare():
|
||||
self.assertEqual(self.exe.read_bytes(), b'new')
|
||||
self.assertEqual(self.backup.read_bytes(), b'old')
|
||||
self.launch.assert_not_called()
|
||||
|
||||
windows_update.apply_update(self.asset, prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'new')
|
||||
self.assertEqual(self.backup.read_bytes(), b'old')
|
||||
self.assertEqual(set(self.exe.parent.iterdir()), {self.exe, self.backup})
|
||||
self.assertEqual(self.launch.call_args[0][0], [str(self.exe), '--portable'])
|
||||
self.assertEqual(self.launch.call_args[1]['creationflags'], 520)
|
||||
|
||||
def test_missing_or_corrupt_checksum_does_not_replace_or_launch(self):
|
||||
for digest in ('', 'sha256:' + '0' * 64):
|
||||
with self.subTest(digest=digest):
|
||||
self.asset['digest'] = digest
|
||||
with self.assertRaises((OSError, ValueError)):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
self.prepare.assert_not_called()
|
||||
self.launch.assert_not_called()
|
||||
|
||||
def test_launch_failure_restores_the_old_executable(self):
|
||||
self.launch.side_effect = OSError('launch failed')
|
||||
with self.assertRaisesRegex(OSError, 'launch failed'):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.prepare.assert_called_once_with()
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
|
||||
def test_rename_failure_leaves_current_executable_and_cleans_download(self):
|
||||
with mock.patch.object(Path, 'rename', side_effect=PermissionError('in use')):
|
||||
with self.assertRaises(PermissionError):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
self.prepare.assert_not_called()
|
||||
self.launch.assert_not_called()
|
||||
|
||||
def test_replacement_failure_rolls_back_and_cleans_download(self):
|
||||
replace = Path.replace
|
||||
|
||||
def fail_download_move(path, target):
|
||||
if path.suffix == '.tmp':
|
||||
raise PermissionError('move failed')
|
||||
return replace(path, target)
|
||||
|
||||
with mock.patch.object(Path, 'replace', fail_download_move):
|
||||
with self.assertRaisesRegex(PermissionError, 'move failed'):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
self.prepare.assert_not_called()
|
||||
self.launch.assert_not_called()
|
||||
|
||||
def test_restart_uses_a_fresh_bundle_and_resets_dll_directory(self):
|
||||
dll = mock.Mock()
|
||||
with mock.patch.object(sys, '_MEIPASS', 'old-bundle'), \
|
||||
mock.patch.object(windows_update.ctypes, 'windll', dll, create=True):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.launch.call_args[1]['env']['PYINSTALLER_RESET_ENVIRONMENT'], '1')
|
||||
calls = dll.kernel32.SetDllDirectoryW.call_args_list
|
||||
self.assertEqual(calls[0], mock.call(None))
|
||||
self.assertEqual(calls[1].args[0].value, 'old-bundle')
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'Windows tray integration')
|
||||
class UpdateFlowTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import windows
|
||||
|
||||
self.windows = windows
|
||||
|
||||
def test_fallback_dialog_actually_installs_the_selected_asset(self):
|
||||
app = self.windows
|
||||
asset = {'url': 'https://example.invalid/new.exe', 'digest': 'sha256:' + '0' * 64}
|
||||
with mock.patch.object(app, 'ctk', None), \
|
||||
mock.patch.object(app._u32, 'MessageBoxW', return_value=app._IDYES), \
|
||||
mock.patch.object(app, '_perform_update') as update:
|
||||
app.update_ctk_form('update', asset=asset)
|
||||
update.assert_called_once_with(asset)
|
||||
|
||||
def test_failed_launch_recovers_proxy_and_lock_instead_of_exiting(self):
|
||||
app = self.windows
|
||||
|
||||
def fail(asset, before_restart):
|
||||
before_restart()
|
||||
raise OSError('launch failed')
|
||||
|
||||
with ExitStack() as stack:
|
||||
mocks = {name: stack.enter_context(mock.patch.object(app, name)) for name in (
|
||||
'apply_update', 'stop_proxy', 'release_lock', '_release_win_mutex',
|
||||
'_acquire_win_mutex', 'acquire_lock', 'start_proxy', '_show_error',
|
||||
)}
|
||||
exit_process = stack.enter_context(mock.patch.object(app.os, '_exit'))
|
||||
mocks['apply_update'].side_effect = fail
|
||||
mocks['_acquire_win_mutex'].return_value = None
|
||||
app._perform_update({'url': 'asset', 'digest': 'digest'})
|
||||
mocks['stop_proxy'].assert_called_once_with()
|
||||
mocks['release_lock'].assert_called_once_with()
|
||||
mocks['_release_win_mutex'].assert_called_once_with()
|
||||
mocks['acquire_lock'].assert_called_once_with()
|
||||
mocks['start_proxy'].assert_called_once_with(app._config, mocks['_show_error'])
|
||||
mocks['_show_error'].assert_called_once()
|
||||
exit_process.assert_not_called()
|
||||
|
||||
def test_download_failure_leaves_proxy_and_locks_running(self):
|
||||
app = self.windows
|
||||
with mock.patch.object(app, 'apply_update', side_effect=OSError('checksum mismatch')), \
|
||||
mock.patch.object(app, 'stop_proxy') as stop, \
|
||||
mock.patch.object(app, 'release_lock') as unlock, \
|
||||
mock.patch.object(app, '_show_error'), \
|
||||
mock.patch.object(app.os, '_exit') as exit_process:
|
||||
app._perform_update({})
|
||||
stop.assert_not_called()
|
||||
unlock.assert_not_called()
|
||||
exit_process.assert_not_called()
|
||||
Reference in new issue
Block a user