mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-21 11:08:44 +00:00
338 lines
15 KiB
Python
338 lines
15 KiB
Python
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):
|
|
self.assertEqual(_parse_version_tuple('1.9.1'), (1, 9, 1))
|
|
self.assertEqual(_parse_version_tuple('v1.9.1'), (1, 9, 1))
|
|
self.assertEqual(_parse_version_tuple(' V2.0 '), (2, 0))
|
|
|
|
def test_trailing_suffixes_are_truncated_to_digits(self):
|
|
self.assertEqual(_parse_version_tuple('1.9.1rc2'), (1, 9, 1))
|
|
self.assertEqual(_parse_version_tuple('1.9.1-beta'), (1, 9, 1))
|
|
|
|
def test_empty_and_non_numeric_segments(self):
|
|
self.assertEqual(_parse_version_tuple(''), (0,))
|
|
self.assertEqual(_parse_version_tuple(None), (0,))
|
|
self.assertEqual(_parse_version_tuple('1.x.3'), (1, 0, 3))
|
|
|
|
|
|
class VersionGtTest(unittest.TestCase):
|
|
def test_newer_versions(self):
|
|
self.assertTrue(_version_gt('1.9.2', '1.9.1'))
|
|
self.assertTrue(_version_gt('1.10.0', '1.9.9'))
|
|
self.assertTrue(_version_gt('2.0', '1.9.9'))
|
|
|
|
def test_equal_and_older_versions(self):
|
|
self.assertFalse(_version_gt('1.9.1', '1.9.1'))
|
|
self.assertFalse(_version_gt('1.9.1', '1.9.2'))
|
|
self.assertFalse(_version_gt('1.9', '1.9.0'))
|
|
|
|
def test_shorter_version_is_padded_with_zeros(self):
|
|
self.assertTrue(_version_gt('1.9.1', '1.9'))
|
|
self.assertFalse(_version_gt('1.9', '1.9.1'))
|
|
|
|
|
|
class ExtractAssetsTest(unittest.TestCase):
|
|
def test_keeps_name_url_and_digest(self):
|
|
data = {'assets': [{
|
|
'name': 'TgWsProxy_windows.exe',
|
|
'browser_download_url': 'https://example.invalid/a.exe',
|
|
'digest': 'sha256:abc',
|
|
}]}
|
|
self.assertEqual(_extract_assets(data), [{
|
|
'name': 'TgWsProxy_windows.exe',
|
|
'url': 'https://example.invalid/a.exe',
|
|
'digest': 'sha256:abc',
|
|
}])
|
|
|
|
def test_drops_entries_without_name_or_url(self):
|
|
data = {'assets': [
|
|
{'name': 'a.exe'},
|
|
{'browser_download_url': 'https://example.invalid/b.exe'},
|
|
]}
|
|
self.assertEqual(_extract_assets(data), [])
|
|
|
|
def test_missing_digest_becomes_empty_string(self):
|
|
data = {'assets': [{
|
|
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe',
|
|
}]}
|
|
self.assertEqual(_extract_assets(data)[0]['digest'], '')
|
|
|
|
def test_empty_input(self):
|
|
self.assertEqual(_extract_assets(None), [])
|
|
self.assertEqual(_extract_assets({}), [])
|
|
|
|
|
|
class _Response(io.BytesIO):
|
|
def __init__(self, body, content_length='auto'):
|
|
super().__init__(body)
|
|
if content_length == 'auto':
|
|
content_length = len(body)
|
|
self.headers = {} if content_length is None else {
|
|
'Content-Length': str(content_length),
|
|
}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
self.close()
|
|
return False
|
|
|
|
|
|
def _opener(response):
|
|
opener = mock.Mock()
|
|
opener.open.return_value = response
|
|
return mock.Mock(return_value=opener)
|
|
|
|
|
|
class DownloadAssetTest(unittest.TestCase):
|
|
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(
|
|
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=digest,
|
|
)
|
|
try:
|
|
return path.read_bytes(), sorted(p.name for p in Path(tmp).iterdir())
|
|
finally:
|
|
path.unlink(missing_ok=True)
|
|
|
|
def test_complete_download_is_kept(self):
|
|
body, files = self._download(_Response(b'payload'))
|
|
self.assertEqual(body, b'payload')
|
|
self.assertEqual(len(files), 1)
|
|
|
|
def test_matching_digest_is_accepted(self):
|
|
digest = 'sha256:' + hashlib.sha256(b'payload').hexdigest()
|
|
body, _ = self._download(_Response(b'payload'), digest=digest)
|
|
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'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', digest=DIGEST,
|
|
)
|
|
self.assertEqual(list(Path(tmp).iterdir()), [])
|
|
|
|
def test_digest_mismatch_raises_and_leaves_no_file(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
resp = _Response(b'payload')
|
|
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', digest='sha256:' + '0' * 64,
|
|
)
|
|
self.assertEqual(list(Path(tmp).iterdir()), [])
|
|
|
|
def test_missing_content_length_is_tolerated(self):
|
|
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': 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'))
|
|
|
|
def test_ignores_asset_without_url(self):
|
|
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()
|