diff --git a/examples/config.yml b/examples/config.yml index 0337e6f0..f15c00da 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -211,6 +211,37 @@ # - url: https://gitlab.com/user/repo # index_only: true + +# These settings allow using `fdroid deploy` for publishing APK files from +# your repository to GitHub Releases. (You should also run `fdroid update` +# every time before deploying to GitHub releases to update index files.) Here's +# an example for this deployment automation: +# https://github.com/f-droid/fdroidclient/releases/ +# +# Currently, versions which are assigned to a release channel (e.g. alpha or +# beta releases) are ignored. +# +# In the example below, tokens are read from environment variables. Putting +# tokens directly into the config file is also supported but discouraged. It is +# highly recommended to use a "Fine-grained personal access token", which is +# restricted to the minimum required permissions, which are: +# * Metadata - read +# * Contents - read/write +# (https://github.com/settings/personal-access-tokens/new) +# +# github_token: {env: GITHUB_TOKEN} +# github_releases: +# - projectUrl: https://github.com/f-droid/fdroidclient +# packageNames: +# - org.fdroid.basic +# - org.fdroid.fdroid +# release_notes_prepend: | +# Re-post of official F-Droid App release from https://f-droid.org +# - projectUrl: https://github.com/example/app +# packageNames: com.example.app +# token: {env: GITHUB_TOKEN_EXAMPLE} + + # Most git hosting services have hard size limits for each git repo. # `fdroid deploy` will delete the git history when the git mirror repo # approaches this limit to ensure that the repo will still fit when diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index e87703c5..8d2b5dca 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -31,9 +31,10 @@ import yaml from argparse import ArgumentParser import logging from shlex import split +import pathlib import shutil import git -from pathlib import Path +import fdroidserver.github from . import _ from . import common @@ -663,7 +664,7 @@ def update_servergitmirrors(servergitmirrors, repo_section): return options = common.get_options() - workspace_dir = Path(os.getcwd()) + workspace_dir = pathlib.Path(os.getcwd()) # right now we support only 'repo' git-mirroring if repo_section == 'repo': @@ -1115,6 +1116,139 @@ def push_binary_transparency(git_repo_path, git_remote): raise FDroidException(_("Pushing to remote server failed!")) +def find_release_infos(index_v2_path, repo_dir, package_names): + """Find files, texts, etc. for uploading to a release page in index-v2.json. + + This function parses index-v2.json for file-paths elegible for deployment + to release pages. (e.g. GitHub releases) It also groups these files by + packageName and versionName. e.g. to get a list of files for all specific + release of fdroid client you may call: + + find_binary_release_infos()['org.fdroid.fdroid']['0.19.2'] + + All paths in the returned data-structure are of type pathlib.Path. + """ + release_infos = {} + with open(index_v2_path, 'r') as f: + idx = json.load(f) + for package_name in package_names: + package = idx.get('packages', {}).get(package_name, {}) + for version in package.get('versions', {}).values(): + if package_name not in release_infos: + release_infos[package_name] = {} + version_name = version['manifest']['versionName'] + version_path = repo_dir / version['file']['name'].lstrip("/") + files = [version_path] + asc_path = pathlib.Path(str(version_path) + '.asc') + if asc_path.is_file(): + files.append(asc_path) + sig_path = pathlib.Path(str(version_path) + '.sig') + if sig_path.is_file(): + files.append(sig_path) + release_infos[package_name][version_name] = { + 'files': files, + 'whatsNew': version.get('whatsNew', {}).get("en-US"), + 'hasReleaseChannels': len(version.get('releaseChannels', [])) > 0, + } + return release_infos + + +def upload_to_github_releases(repo_section, gh_config, global_gh_token): + repo_dir = pathlib.Path(repo_section) + index_v2_path = repo_dir / 'index-v2.json' + if not index_v2_path.is_file(): + logging.warning( + _( + "Error deploying 'github_releases', {} not present. (You might " + "need to run `fdroid update` first.)" + ).format(index_v2_path) + ) + return + + package_names = [] + for repo_conf in gh_config: + for package_name in repo_conf.get('packageNames', []): + package_names.append(package_name) + + release_infos = fdroidserver.deploy.find_release_infos( + index_v2_path, repo_dir, package_names + ) + + for repo_conf in gh_config: + upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token) + + +def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token): + projectUrl = repo_conf.get("projectUrl") + if not projectUrl: + logging.warning( + _( + "One of the 'github_releases' config items is missing the " + "'projectUrl' value. skipping ..." + ) + ) + return + token = repo_conf.get("token") or global_gh_token + if not token: + logging.warning( + _( + "One of the 'github_releases' config itmes is missing the " + "'token' value. skipping ..." + ) + ) + return + conf_package_names = repo_conf.get("packageNames", []) + if type(conf_package_names) == str: + conf_package_names = [conf_package_names] + if not conf_package_names: + logging.warning( + _( + "One of the 'github_releases' config itmes is missing the " + "'packageNames' value. skipping ..." + ) + ) + return + + # lookup all versionNames (git tags) for all packages available in the + # local fdroid repo + all_local_versions = set() + for package_name in conf_package_names: + for version in release_infos.get(package_name, {}).keys(): + all_local_versions.add(version) + + gh = fdroidserver.github.GithubApi(token, projectUrl) + unreleased_tags = gh.list_unreleased_tags() + + for version in all_local_versions: + if version in unreleased_tags: + # Making sure we're not uploading this version when releaseChannels + # is set. (releaseChannels usually mean it's e.g. an alpha or beta + # version) + if ( + not release_infos.get(conf_package_names[0], {}) + .get(version, {}) + .get('hasReleaseChannels') + ): + # collect files associated with this github release + files = [] + for package in conf_package_names: + files.extend( + release_infos.get(package, {}).get(version, {}).get('files', []) + ) + # always use the whatsNew text from the first app listed in + # config.yml github_releases.packageNames + text = ( + release_infos.get(conf_package_names[0], {}) + .get(version, {}) + .get('whatsNew') + or '' + ) + if 'release_notes_prepend' in repo_conf: + text = repo_conf['release_notes_prepend'] + "\n\n" + text + # create new release on github and upload all associated files + gh.create_release(version, files, text) + + def main(): global config @@ -1194,12 +1328,14 @@ def main(): and not config.get('androidobservatory') and not config.get('binary_transparency_remote') and not config.get('virustotal_apikey') + and not config.get('github_releases') and local_copy_dir is None ): logging.warning( _('No option set! Edit your config.yml to set at least one of these:') + '\nserverwebroot, servergitmirrors, local_copy_dir, awsbucket, ' - + 'virustotal_apikey, androidobservatory, or binary_transparency_remote' + + 'virustotal_apikey, androidobservatory, github_releases ' + + 'or binary_transparency_remote' ) sys.exit(1) @@ -1236,6 +1372,10 @@ def main(): upload_to_android_observatory(repo_section) if config.get('virustotal_apikey'): upload_to_virustotal(repo_section, config.get('virustotal_apikey')) + if config.get('github_releases'): + upload_to_github_releases( + repo_section, config.get('github_releases'), config.get('github_token') + ) binary_transparency_remote = config.get('binary_transparency_remote') if binary_transparency_remote: diff --git a/fdroidserver/github.py b/fdroidserver/github.py new file mode 100644 index 00000000..c522f522 --- /dev/null +++ b/fdroidserver/github.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# +# github.py - part of the FDroid server tools +# Copyright (C) 2024, Michael Pöhn, michael@poehn.at +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import pathlib +import urllib.request +import urllib.parse + + +class GithubApi: + """ + Warpper for some select calls to GitHub Json/REST API. + + This class wraps some calls to api.github.com. This is not intended to be a + general API wrapper. Instead it's purpose is to return pre-filtered and + transformed data that's playing well with other fdroidserver functions. + """ + + def __init__(self, api_token, repo_path): + self._api_token = api_token + if repo_path.startswith("https://github.com/"): + self._repo_path = repo_path[19:] + else: + self._repo_path = repo_path + + def _req(self, url, data=None): + h = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self._api_token}", + "X-GitHub-Api-Version": "2022-11-28", + } + return urllib.request.Request( + url, + headers=h, + data=data, + ) + + def list_released_tags(self): + """List of all tags that are associated with a release for this repo on GitHub.""" + names = [] + req = self._req(f"https://api.github.com/repos/{self._repo_path}/releases") + with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning + releases = json.load(resp) + for release in releases: + names.append(release['tag_name']) + return names + + def list_unreleased_tags(self): + all_tags = self.list_all_tags() + released_tags = self.list_released_tags() + return [x for x in all_tags if x not in released_tags] + + def tag_exists(self, tag): + """ + Check if git tag is present on github. + + https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#list-matching-references--fine-grained-access-tokens + """ + req = self._req( + f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/{tag}" + ) + with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning + rd = json.load(resp) + return len(rd) == 1 and rd[0].get("ref", False) == f"refs/tags/{tag}" + return False + + def list_all_tags(self): + """Get list of all tags for this repo on GitHub.""" + tags = [] + req = self._req( + f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/" + ) + with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning + refs = json.load(resp) + for ref in refs: + r = ref.get('ref', '') + if r.startswith('refs/tags/'): + tags.append(r[10:]) + return tags + + def create_release(self, tag, files, body=''): + """ + Create a new release on github. + + also see: https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release + + :returns: True if release was created, False if release already exists + :raises: urllib exceptions in case of network or api errors, also + raises an exception when the tag doesn't exists. + """ + # Querying github to create a new release for a non-existent tag, will + # also create that tag on github. So we need an additional check to + # prevent this behavior. + if not self.tag_exists(tag): + raise Exception( + f"can't create github release for {self._repo_path} {tag}, tag doesn't exists" + ) + # create the relase on github + req = self._req( + f"https://api.github.com/repos/{self._repo_path}/releases", + data=json.dumps( + { + "tag_name": tag, + "body": body, + } + ).encode("utf-8"), + ) + try: + with urllib.request.urlopen( # nosec CWE-22 disable bandit warning + req + ) as resp: + release_id = json.load(resp)['id'] + except urllib.error.HTTPError as e: + if e.status == 422: + codes = [x['code'] for x in json.load(e).get('errors', [])] + if "already_exists" in codes: + return False + raise e + + # attach / upload all files for the relase + for file in files: + self._create_release_asset(release_id, file) + + return True + + def _create_release_asset(self, release_id, file): + """ + Attach a file to a release on GitHub. + + This uploads a file to github relases, it will be attached to the supplied release + + also see: https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset + """ + file = pathlib.Path(file) + with open(file, 'rb') as f: + req = urllib.request.Request( + f"https://uploads.github.com/repos/{self._repo_path}/releases/{release_id}/assets?name={file.name}", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self._api_token}", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/octet-stream", + }, + data=f.read(), + ) + with urllib.request.urlopen(req): # nosec CWE-22 disable bandit warning + return True + return False diff --git a/tests/deploy.TestCase b/tests/deploy.TestCase index fa87f7b6..3d45625d 100755 --- a/tests/deploy.TestCase +++ b/tests/deploy.TestCase @@ -1210,6 +1210,261 @@ class DeployTest(unittest.TestCase): ) +class GitHubReleasesTest(unittest.TestCase): + def test_find_release_infos(self): + self.maxDiff = None + + index_mock = b""" + { + "packages": { + "com.example.app": { + "versions": { + "2e6f263c1927506015bfc98bce0818247836f2e7fe29a04e1af2b33c97848750": { + "file": { + "name": "/com.example.app_123.apk" + }, + "whatsNew": { + "en-US": "fake what's new" + }, + "manifest": { + "versionName": "1.2.3", + "versionCode": "123" + } + }, + "8a6f263c8327506015bfc98bce0815247836f2e7fe29a04e1af2bffa6409998d": { + "file": { + "name": "/com.example.app_100.apk" + }, + "manifest": { + "versionName": "1.0-alpha", + "versionCode": "123" + }, + "releaseChannels": ["alpha"] + } + } + }, + "another.app": { + "versions": { + "30602ffc19a7c0601bbfa93bce00082c78a6f2ddfe29a04e1af253fc9f84eda0": { + "file": { + "name": "/another.app_1.apk" + }, + "manifest": { + "versionName": "1", + "versionCode": "1" + } + } + } + }, + "fildered.app": { + "versions": { + "93ae02fc19a7c0601adfa93bce0443fc78a6f2ddfe3df04e1af093fca9a1ff09": { + "file": { + "name": "/another.app_1.apk" + }, + "manifest": { + "versionName": "1", + "versionCode": "1" + } + } + } + } + } + } + """ + with unittest.mock.patch( + "fdroidserver.deploy.open", unittest.mock.mock_open(read_data=index_mock) + ): + release_infos = fdroidserver.deploy.find_release_infos( + "fake_path", + Path('fake_repo'), + ["com.example.app", "another.app"], + ) + + self.assertDictEqual( + release_infos, + { + "another.app": { + "1": { + "files": [Path('fake_repo') / "another.app_1.apk"], + "hasReleaseChannels": False, + "whatsNew": None, + }, + }, + "com.example.app": { + "1.0-alpha": { + "files": [ + Path("fake_repo") / "com.example.app_100.apk", + ], + "hasReleaseChannels": True, + "whatsNew": None, + }, + "1.2.3": { + "files": [ + Path("fake_repo") / "com.example.app_123.apk", + ], + "hasReleaseChannels": False, + "whatsNew": "fake what's new", + }, + }, + }, + ) + + def test_upload_to_github_releases(self): + gh_config = [ + { + "projectUrl": "https://github.com/example/app", + "packageNames": ["com.example.app", "another.app"], + }, + { + "projectUrl": "https://github.com/custom/app", + "packageNames": ["more.custom.app"], + "token": "custom_token", + }, + ] + + fri_mock = unittest.mock.Mock(return_value="fri_result") + urr_mock = unittest.mock.Mock() + with unittest.mock.patch( + "fdroidserver.deploy.find_release_infos", fri_mock + ), unittest.mock.patch( + "fdroidserver.deploy.upload_to_github_releases_repo", urr_mock + ), tempfile.TemporaryDirectory() as tmpdir: + with open(Path(tmpdir) / "index-v2.json", "w") as f: + f.write("") + + fdroidserver.deploy.upload_to_github_releases( + tmpdir, gh_config, "fake_global_token" + ) + + fri_mock.assert_called_once_with( + Path(tmpdir) / "index-v2.json", + Path(tmpdir), + ["com.example.app", "another.app", "more.custom.app"], + ) + + self.maxDiff = None + self.assertListEqual( + urr_mock.call_args_list, + [ + unittest.mock.call( + { + "projectUrl": "https://github.com/example/app", + "packageNames": ["com.example.app", "another.app"], + }, + "fri_result", + "fake_global_token", + ), + unittest.mock.call( + { + "projectUrl": "https://github.com/custom/app", + "packageNames": ["more.custom.app"], + "token": "custom_token", + }, + "fri_result", + "fake_global_token", + ), + ], + ) + + +class Test_UploadToGithubReleasesRepo(unittest.TestCase): + def setUp(self): + self.repo_conf = { + "projectUrl": "https://github.com/example/app", + "packageNames": ["com.example.app", "com.example.altapp", "another.app"], + } + self.release_infos = { + "com.example.app": { + "1.0.0": { + "files": [ + Path("fake_repo") / "com.example.app_100100.apk", + ], + "hasReleaseChannels": False, + "whatsNew": "what's new com.example.app 1.0.0", + }, + "1.0.0-beta1": { + "files": [ + Path("fake_repo") / "com.example.app_100007.apk", + ], + "hasReleaseChannels": True, + "whatsNew": None, + }, + }, + "com.example.altapp": { + "1.0.0": { + "files": [ + Path("fake_repo") / "com.example.altapp_100100.apk", + Path("fake_repo") / "com.example.altapp_100100.apk.asc", + Path("fake_repo") / "com.example.altapp_100100.apk.idsig", + ], + "whatsNew": "what's new com.example.altapp 1.0.0", + }, + }, + } + + self.api = unittest.mock.Mock() + self.api.list_unreleased_tags = lambda: ["1.0.0", "1.0.0-beta1"] + self.api_constructor = unittest.mock.Mock(return_value=self.api) + + def test_global_token(self): + with unittest.mock.patch("fdroidserver.github.GithubApi", self.api_constructor): + fdroidserver.deploy.upload_to_github_releases_repo( + self.repo_conf, + self.release_infos, + "global_token", + ) + + self.api_constructor.assert_called_once_with( + "global_token", "https://github.com/example/app" + ) + + self.assertListEqual( + self.api.create_release.call_args_list, + [ + unittest.mock.call( + "1.0.0", + [ + Path("fake_repo/com.example.app_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk.asc"), + Path("fake_repo/com.example.altapp_100100.apk.idsig"), + ], + "what's new com.example.app 1.0.0", + ), + ], + ) + + def test_local_token(self): + self.repo_conf["token"] = "local_token" + with unittest.mock.patch("fdroidserver.github.GithubApi", self.api_constructor): + fdroidserver.deploy.upload_to_github_releases_repo( + self.repo_conf, + self.release_infos, + "global_token", + ) + + self.api_constructor.assert_called_once_with( + "local_token", "https://github.com/example/app" + ) + + self.assertListEqual( + self.api.create_release.call_args_list, + [ + unittest.mock.call( + "1.0.0", + [ + Path("fake_repo/com.example.app_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk"), + Path("fake_repo/com.example.altapp_100100.apk.asc"), + Path("fake_repo/com.example.altapp_100100.apk.idsig"), + ], + "what's new com.example.app 1.0.0", + ), + ], + ) + + if __name__ == "__main__": os.chdir(os.path.dirname(__file__)) @@ -1227,4 +1482,6 @@ if __name__ == "__main__": newSuite = unittest.TestSuite() newSuite.addTest(unittest.makeSuite(DeployTest)) + newSuite.addTest(unittest.makeSuite(GitHubReleasesTest)) + newSuite.addTest(unittest.makeSuite(Test_UploadToGithubReleasesRepo)) unittest.main(failfast=False) diff --git a/tests/github.TestCase b/tests/github.TestCase new file mode 100755 index 00000000..608d7215 --- /dev/null +++ b/tests/github.TestCase @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +import inspect +import optparse +import os +import sys +import unittest.mock +import testcommon + +localmodule = os.path.realpath( + os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..') +) +print('localmodule: ' + localmodule) +if localmodule not in sys.path: + sys.path.insert(0, localmodule) + +import fdroidserver.github +import fdroidserver.common + + +class GithubApiTest(unittest.TestCase): + def test__init(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + self.assertEqual(api._api_token, 'faketoken') + self.assertEqual(api._repo_path, 'fakerepopath') + + def test__req(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + r = api._req('https://fakeurl', data='fakedata') + self.assertEqual(r.full_url, 'https://fakeurl') + self.assertEqual(r.data, "fakedata") + self.assertDictEqual( + r.headers, + { + 'Accept': 'application/vnd.github+json', + 'Authorization': 'Bearer faketoken', + 'X-github-api-version': '2022-11-28', + }, + ) + + def test_list_released_tags(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + uomock = testcommon.mock_urlopen( + body='[{"tag_name": "fake"}, {"tag_name": "double_fake"}]' + ) + with unittest.mock.patch("urllib.request.urlopen", uomock): + result = api.list_released_tags() + self.assertListEqual(result, ['fake', 'double_fake']) + + def test_list_unreleased_tags(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + api.list_all_tags = unittest.mock.Mock(return_value=[1, 2, 3, 4]) + api.list_released_tags = unittest.mock.Mock(return_value=[1, 2]) + + result = api.list_unreleased_tags() + + self.assertListEqual(result, [3, 4]) + + def test_tag_exists(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + uomock = testcommon.mock_urlopen(body='[{"ref": "refs/tags/fake_tag"}]') + with unittest.mock.patch("urllib.request.urlopen", uomock): + result = api.tag_exists('fake_tag') + self.assertTrue(result) + + def test_tag_exists_failure(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + uomock = testcommon.mock_urlopen(body='[{"error": "failure"}]') + + with unittest.mock.patch("urllib.request.urlopen", uomock): + success = api.tag_exists('fake_tag') + + self.assertFalse(success) + + def test_list_all_tags(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + uomock = testcommon.mock_urlopen( + body='[{"ref": "refs/tags/fake"}, {"ref": "refs/tags/double_fake"}]' + ) + + with unittest.mock.patch("urllib.request.urlopen", uomock): + result = api.list_all_tags() + + self.assertListEqual(result, ['fake', 'double_fake']) + + def test_create_release(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + + uomock = testcommon.mock_urlopen(body='{"id": "fakeid"}') + api.tag_exists = lambda x: True + api._create_release_asset = unittest.mock.Mock() + + with unittest.mock.patch("urllib.request.urlopen", uomock): + success = api.create_release('faketag', ['file_a', 'file_b'], body="bdy") + self.assertTrue(success) + + req = uomock.call_args_list[0][0][0] + self.assertEqual(1, len(uomock.call_args_list)) + self.assertEqual(2, len(uomock.call_args_list[0])) + self.assertEqual(1, len(uomock.call_args_list[0][0])) + self.assertEqual( + req.full_url, + 'https://api.github.com/repos/fakerepopath/releases', + ) + self.assertEqual(req.data, b'{"tag_name": "faketag", "body": "bdy"}') + self.assertListEqual( + api._create_release_asset.call_args_list, + [ + unittest.mock.call('fakeid', 'file_a'), + unittest.mock.call('fakeid', 'file_b'), + ], + ) + + def test__create_release_asset(self): + api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath') + uomock = testcommon.mock_urlopen() + + with unittest.mock.patch( + 'fdroidserver.github.open', + unittest.mock.mock_open(read_data=b"fake_content"), + ), unittest.mock.patch("urllib.request.urlopen", uomock): + success = api._create_release_asset('fake_id', 'fake_file') + + self.assertTrue(success) + + req = uomock.call_args_list[0][0][0] + self.assertEqual(1, len(uomock.call_args_list)) + self.assertEqual(2, len(uomock.call_args_list[0])) + self.assertEqual(1, len(uomock.call_args_list[0][0])) + self.assertEqual( + req.full_url, + 'https://uploads.github.com/repos/fakerepopath/releases/fake_id/assets?name=fake_file', + ) + self.assertDictEqual( + req.headers, + { + "Accept": "application/vnd.github+json", + 'Authorization': 'Bearer faketoken', + 'Content-type': 'application/octet-stream', + 'X-github-api-version': '2022-11-28', + }, + ) + self.assertEqual(req.data, b'fake_content') + + +if __name__ == "__main__": + os.chdir(os.path.dirname(__file__)) + + parser = optparse.OptionParser() + parser.add_option( + "-v", + "--verbose", + action="store_true", + default=False, + help="Spew out even more information than normal", + ) + (fdroidserver.common.options, args) = parser.parse_args(["--verbose"]) + + newSuite = unittest.TestSuite() + newSuite.addTest(unittest.makeSuite(GithubApiTest)) + unittest.main(failfast=False) diff --git a/tests/testcommon.py b/tests/testcommon.py index 2ce9f393..f0fd11bd 100644 --- a/tests/testcommon.py +++ b/tests/testcommon.py @@ -19,9 +19,9 @@ import os import sys import tempfile import unittest +import unittest.mock from pathlib import Path -from unittest import mock class TmpCwd: @@ -84,5 +84,13 @@ def parse_args_for_test(parser, args): for arg in args: if arg[0] == '-': flags.append(flags) - with mock.patch('sys.argv', flags): + with unittest.mock.patch('sys.argv', flags): parse_args(parser) + + +def mock_urlopen(status=200, body=None): + resp = unittest.mock.MagicMock() + resp.getcode.return_value = status + resp.read.return_value = body + resp.__enter__.return_value = resp + return unittest.mock.Mock(return_value=resp)