From c805267c957b52131358a374563055c0d28e1bf0 Mon Sep 17 00:00:00 2001 From: Bill Napier Date: Thu, 13 Apr 2023 01:03:31 +0000 Subject: [PATCH 1/3] Add Create Organization call. --- github_nonpublic_api/api.py | 103 ++++++++++++++++++++++++++++-------- tests/github_form.html | 4 +- tests/new_org_form.html | 8 +++ tests/test_api.py | 61 +++++++++++++++++---- 4 files changed, 142 insertions(+), 34 deletions(-) create mode 100644 tests/new_org_form.html diff --git a/github_nonpublic_api/api.py b/github_nonpublic_api/api.py index 1143c67..eacf99f 100644 --- a/github_nonpublic_api/api.py +++ b/github_nonpublic_api/api.py @@ -1,61 +1,118 @@ -from configobj import ConfigObj -import html5lib +"""An API to do things that aren't in the official public GitHub REST API.""" + +from urllib.parse import urljoin +from enum import Enum + import os.path +import html5lib import pyotp import requests -from urllib.parse import urljoin +from configobj import ConfigObj -def _get_and_submit_form(session, url, data_callback): +def _get_and_submit_form(session, url: str, data_callback = None, form_id: str = None): response = session.get(url) response.raise_for_status() doc = html5lib.parse(response.text, namespaceHTMLElements=False) - form = doc.findall('.//form') - inputs = doc.findall('.//form//input') - - action_url = form[0].attrib['action'] + forms = doc.findall('.//form') + + # If no form_id is specified, just use the first (and probably only) + # form. Otherwise find the named form to submit to. + submit_form = forms[0] + if form_id: + for form in forms: + if form.attrib.get('id') == form_id: + submit_form = form + break + else: + raise ValueError('%s form not found' % form_id) + + action_url = submit_form.attrib['action'] + # Look at all the inputs under the given form. + inputs = submit_form.findall('.//input') data = dict() - for input in inputs: - value = input.attrib.get('value') - if value: - data[input.attrib['name']] = value + for form_input in inputs: + value = form_input.attrib.get('value') + if value and 'name' in form_input.attrib: + data[form_input.attrib['name']] = value # Have the caller provide additional data - data_callback(data) + if data_callback: + data_callback(data) response = session.post(urljoin(url, action_url), data=data) response.raise_for_status() return response -def _create_login_session(username, password, tfa_callback, session=None): +def _create_login_session(username: str, password: str, + tfa_callback, session: requests.Session = None) -> requests.Session: session = session or requests.Session() - def _LoginCallback(data): + def _login_callback(data): data.update(dict(login=username, password=password)) _get_and_submit_form( - session=session, url='https://github.com/login', data_callback=_LoginCallback) + session=session, url='https://github.com/login', data_callback=_login_callback) - def _TfaCallback(data): + def _tfa_callback(data): data.update(dict(otp=tfa_callback())) _get_and_submit_form( - session=session, url='https://github.com/sessions/two-factor', data_callback=_TfaCallback) + session=session, url='https://github.com/sessions/two-factor', data_callback=_tfa_callback) return session +_CREATE_ORG_URL = 'https://github.com/account/organizations/new?plan=free' + + +class OrganizationUsage(Enum): + """Organization Usage for Organization Creation.""" + + PERSONAL = 'standard' + BUSINESS = 'corporate' + + class Api(object): - def __init__(self, username, password, tfa_callback, session=None): - self._session = _create_login_session( + """API Endpoing for doing non-public things to GitHub. + + Ideally these would all exist as REST API endpoints, but instead we get + to pretend to be a real user. + """ + + def __init__(self, username: str = None, password: str = None, tfa_callback = None, + session: requests.Session = None): + self._session = session or _create_login_session( username=username, password=password, tfa_callback=tfa_callback, session=session) + def create_organization(self, org_name: str, contact_email: str, + org_usage: OrganizationUsage, business_name: str = None): + """Create the specified GitHub organization. + + Right now, only creates free tier organizations. + """ + + def _create_org_callback(data): + data['organization[profile_name]'] = org_name + data['organization[billing_email]'] = contact_email + data['terms_of_service_type'] = org_usage.value + data['agreed_to_terms'] = 'yes' + if org_usage == OrganizationUsage.BUSINESS: + data['organization[company_name]'] = business_name + + _get_and_submit_form(session=self._session, + url=_CREATE_ORG_URL, data_callback=_create_org_callback, + form_id='org-new-form') + + if __name__ == "__main__": config = ConfigObj(os.path.expanduser('~/github.ini'), _inspec=True) api = Api(config['username'], config['password'], - tfa_callback=lambda: pyotp.TOTP(config['otp_seed']).now()) - resp = api._session.get('https://github.com/billnapier/aoc2021/settings') - resp.raise_for_status() + tfa_callback=lambda: pyotp.TOTP(config['otp_seed']).now()) + api.create_organization(org_name='billnapier-test4', + contact_email='napier@google.com', + org_usage=OrganizationUsage.BUSINESS, + business_name='Alphabet') diff --git a/tests/github_form.html b/tests/github_form.html index b7372e6..88b07ed 100644 --- a/tests/github_form.html +++ b/tests/github_form.html @@ -4,6 +4,8 @@
-
fake form
+
+ +
\ No newline at end of file diff --git a/tests/new_org_form.html b/tests/new_org_form.html new file mode 100644 index 0000000..5a77cec --- /dev/null +++ b/tests/new_org_form.html @@ -0,0 +1,8 @@ + + + +
+ +
+ + \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index a1710e4..0335107 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,24 +1,65 @@ -from github_nonpublic_api import api +"""Unit tests for api.py.""" import os +from unittest import TestCase, mock + from truth.truth import AssertThat -import unittest -from unittest import mock + +from github_nonpublic_api import api GITHUB_FORM_HTML = os.path.join(os.path.dirname(__file__), 'github_form.html') +NEW_ORG_FORM_HTML = os.path.join( + os.path.dirname(__file__), 'new_org_form.html') -class TestApi(unittest.TestCase): +class TestApi(TestCase): + def _seed_session_with_file(self, filename): + self.session = mock.MagicMock() + with open(filename) as fp: + self.session.get.return_value.text = fp.read() + + """Unit tests for api.py.""" + def test_get_and_submit_form(self): - session = mock.MagicMock() - with open(GITHUB_FORM_HTML) as fp: - session.get.return_value.text = fp.read() + self._seed_session_with_file(GITHUB_FORM_HTML) - def DataCallback(data): + def _data_callback(data): data['add'] = 'yes' api._get_and_submit_form( - session=session, url='http://github.com', data_callback=DataCallback) + session=self.session, url='http://github.com', data_callback=_data_callback) - AssertThat(session.post).WasCalled().Once().With( + AssertThat(self.session.post).WasCalled().Once().With( 'http://github.com/foo', data=dict(add='yes', key='value')) + + def test_get_and_submit_form_by_id(self): + self._seed_session_with_file(GITHUB_FORM_HTML) + + api._get_and_submit_form( + session=self.session, url='http://github.com', form_id='form2') + + AssertThat(self.session.post).WasCalled().Once().With( + 'http://github.com/form2', data=dict(key='value2')) + + def test_get_and_submit_form_by_id_error(self): + self._seed_session_with_file(GITHUB_FORM_HTML) + + with AssertThat(ValueError).IsRaised(): + api._get_and_submit_form( + session=self.session, url='http://github.com', form_id='no_form') + + def test_create_business_org(self): + self._seed_session_with_file(NEW_ORG_FORM_HTML) + gh = api.Api(session=self.session) + gh.create_organization(org_name='test', contact_email='nobody@google.com', + org_usage=api.OrganizationUsage.BUSINESS, + business_name='A Fake Business') + AssertThat(self.session.post).WasCalled().Once().With( + 'https://github.com/account/organizations/new_org', data={ + 'authenticity_token': 'value', + 'agreed_to_terms': 'yes', + 'terms_of_service_type': 'corporate', + 'organization[billing_email]': 'nobody@google.com', + 'organization[profile_name]': 'test', + 'organization[company_name]': 'A Fake Business', + }) From 45c383eaf8f2e39669a7a0a41eb19ac32e958c28 Mon Sep 17 00:00:00 2001 From: Bill Napier Date: Thu, 13 Apr 2023 01:06:29 +0000 Subject: [PATCH 2/3] Add a personal org unit test. --- tests/test_api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index 0335107..906f582 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -63,3 +63,17 @@ def test_create_business_org(self): 'organization[profile_name]': 'test', 'organization[company_name]': 'A Fake Business', }) + + def test_create_personal_org(self): + self._seed_session_with_file(NEW_ORG_FORM_HTML) + gh = api.Api(session=self.session) + gh.create_organization(org_name='test', contact_email='nobody@google.com', + org_usage=api.OrganizationUsage.PERSONAL) + AssertThat(self.session.post).WasCalled().Once().With( + 'https://github.com/account/organizations/new_org', data={ + 'authenticity_token': 'value', + 'agreed_to_terms': 'yes', + 'terms_of_service_type': 'standard', + 'organization[billing_email]': 'nobody@google.com', + 'organization[profile_name]': 'test', + }) From 5c7b3fe194d86efe25c6118dfb7b4c3598e3d32d Mon Sep 17 00:00:00 2001 From: Bill Napier Date: Sat, 15 Apr 2023 01:33:54 +0000 Subject: [PATCH 3/3] Change example org creation to be less real-ish. --- github_nonpublic_api/api.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/github_nonpublic_api/api.py b/github_nonpublic_api/api.py index eacf99f..f3585e2 100644 --- a/github_nonpublic_api/api.py +++ b/github_nonpublic_api/api.py @@ -112,7 +112,6 @@ def _create_org_callback(data): api = Api(config['username'], config['password'], tfa_callback=lambda: pyotp.TOTP(config['otp_seed']).now()) - api.create_organization(org_name='billnapier-test4', - contact_email='napier@google.com', - org_usage=OrganizationUsage.BUSINESS, - business_name='Alphabet') + api.create_organization(org_name='blah', + contact_email='example@example.com', + org_usage=OrganizationUsage.PERSONAL)