Adding common sign_blob() service account types.
Also adding service_account_email() property.
This commit is contained in:
@@ -1617,6 +1617,18 @@ class AssertionCredentials(GoogleCredentials):
|
|||||||
"""
|
"""
|
||||||
self._do_revoke(http_request, self.access_token)
|
self._do_revoke(http_request, self.access_token)
|
||||||
|
|
||||||
|
def sign_blob(self, blob):
|
||||||
|
"""Cryptographically sign a blob (of bytes).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blob: bytes, Message to be signed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple, A pair of the private key ID used to sign the blob and
|
||||||
|
the signed contents.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError('This method is abstract.')
|
||||||
|
|
||||||
|
|
||||||
def _RequireCryptoOrDie():
|
def _RequireCryptoOrDie():
|
||||||
"""Ensure we have a crypto library, or throw CryptoUnavailableError.
|
"""Ensure we have a crypto library, or throw CryptoUnavailableError.
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class AppAssertionCredentials(AssertionCredentials):
|
|||||||
self.scope = util.scopes_to_string(scope)
|
self.scope = util.scopes_to_string(scope)
|
||||||
self._kwargs = kwargs
|
self._kwargs = kwargs
|
||||||
self.service_account_id = kwargs.get('service_account_id', None)
|
self.service_account_id = kwargs.get('service_account_id', None)
|
||||||
|
self._service_account_email = None
|
||||||
|
|
||||||
# Assertion type is no longer used, but still in the
|
# Assertion type is no longer used, but still in the
|
||||||
# parent class signature.
|
# parent class signature.
|
||||||
@@ -210,6 +211,34 @@ class AppAssertionCredentials(AssertionCredentials):
|
|||||||
def create_scoped(self, scopes):
|
def create_scoped(self, scopes):
|
||||||
return AppAssertionCredentials(scopes, **self._kwargs)
|
return AppAssertionCredentials(scopes, **self._kwargs)
|
||||||
|
|
||||||
|
def sign_blob(self, blob):
|
||||||
|
"""Cryptographically sign a blob (of bytes).
|
||||||
|
|
||||||
|
Implements abstract method
|
||||||
|
:meth:`oauth2client.client.AssertionCredentials.sign_blob`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blob: bytes, Message to be signed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple, A pair of the private key ID used to sign the blob and
|
||||||
|
the signed contents.
|
||||||
|
"""
|
||||||
|
return app_identity.sign_blob(blob)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_account_email(self):
|
||||||
|
"""Get the email for the current service account.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
string, The email associated with the Google App Engine
|
||||||
|
service account.
|
||||||
|
"""
|
||||||
|
if self._service_account_email is None:
|
||||||
|
self._service_account_email = (
|
||||||
|
app_identity.get_service_account_name())
|
||||||
|
return self._service_account_email
|
||||||
|
|
||||||
|
|
||||||
class FlowProperty(db.Property):
|
class FlowProperty(db.Property):
|
||||||
"""App Engine datastore Property for Flow.
|
"""App Engine datastore Property for Flow.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
import httplib2
|
||||||
from six.moves import http_client
|
from six.moves import http_client
|
||||||
from six.moves import urllib
|
from six.moves import urllib
|
||||||
|
|
||||||
@@ -35,8 +36,10 @@ __author__ = 'jcgregorio@google.com (Joe Gregorio)'
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# URI Template for the endpoint that returns access_tokens.
|
# URI Template for the endpoint that returns access_tokens.
|
||||||
META = ('http://metadata.google.internal/computeMetadata/v1/instance/'
|
_METADATA_ROOT = ('http://metadata.google.internal/computeMetadata/v1/'
|
||||||
'service-accounts/default/token')
|
'instance/service-accounts/default/')
|
||||||
|
META = _METADATA_ROOT + 'token'
|
||||||
|
_DEFAULT_EMAIL_METADATA = _METADATA_ROOT + 'email'
|
||||||
_SCOPES_WARNING = """\
|
_SCOPES_WARNING = """\
|
||||||
You have requested explicit scopes to be used with a GCE service account.
|
You have requested explicit scopes to be used with a GCE service account.
|
||||||
Using this argument will have no effect on the actual scopes for tokens
|
Using this argument will have no effect on the actual scopes for tokens
|
||||||
@@ -45,6 +48,30 @@ can't be overridden in the request.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service_account_email(http_request=None):
|
||||||
|
"""Get the GCE service account email from the current environment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
http_request: callable, (Optional) a callable that matches the method
|
||||||
|
signature of httplib2.Http.request, used to make
|
||||||
|
the request to the metadata service.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple, A pair where the first entry is an optional response (from a
|
||||||
|
failed request) and the second is service account email found (as
|
||||||
|
a string).
|
||||||
|
"""
|
||||||
|
if http_request is None:
|
||||||
|
http_request = httplib2.Http().request
|
||||||
|
response, content = http_request(
|
||||||
|
_DEFAULT_EMAIL_METADATA, headers={'Metadata-Flavor': 'Google'})
|
||||||
|
if response.status == http_client.OK:
|
||||||
|
content = _from_bytes(content)
|
||||||
|
return None, content
|
||||||
|
else:
|
||||||
|
return response, content
|
||||||
|
|
||||||
|
|
||||||
class AppAssertionCredentials(AssertionCredentials):
|
class AppAssertionCredentials(AssertionCredentials):
|
||||||
"""Credentials object for Compute Engine Assertion Grants
|
"""Credentials object for Compute Engine Assertion Grants
|
||||||
|
|
||||||
@@ -78,6 +105,7 @@ class AppAssertionCredentials(AssertionCredentials):
|
|||||||
# Assertion type is no longer used, but still in the
|
# Assertion type is no longer used, but still in the
|
||||||
# parent class signature.
|
# parent class signature.
|
||||||
super(AppAssertionCredentials, self).__init__(None)
|
super(AppAssertionCredentials, self).__init__(None)
|
||||||
|
self._service_account_email = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, json_data):
|
def from_json(cls, json_data):
|
||||||
@@ -123,3 +151,44 @@ class AppAssertionCredentials(AssertionCredentials):
|
|||||||
|
|
||||||
def create_scoped(self, scopes):
|
def create_scoped(self, scopes):
|
||||||
return AppAssertionCredentials(scopes, **self.kwargs)
|
return AppAssertionCredentials(scopes, **self.kwargs)
|
||||||
|
|
||||||
|
def sign_blob(self, blob):
|
||||||
|
"""Cryptographically sign a blob (of bytes).
|
||||||
|
|
||||||
|
This method is provided to support a common interface, but
|
||||||
|
the actual key used for a Google Compute Engine service account
|
||||||
|
is not available, so it can't be used to sign content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blob: bytes, Message to be signed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotImplementedError, always.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
'Compute Engine service accounts cannot sign blobs')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_account_email(self):
|
||||||
|
"""Get the email for the current service account.
|
||||||
|
|
||||||
|
Uses the Google Compute Engine metadata service to retrieve the email
|
||||||
|
of the default service account.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
string, The email associated with the Google Compute Engine
|
||||||
|
service account.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AttributeError, if the email can not be retrieved from the Google
|
||||||
|
Compute Engine metadata service.
|
||||||
|
"""
|
||||||
|
if self._service_account_email is None:
|
||||||
|
failure, email = _get_service_account_email()
|
||||||
|
if failure is None:
|
||||||
|
self._service_account_email = email
|
||||||
|
else:
|
||||||
|
raise AttributeError('Failed to retrieve the email from the '
|
||||||
|
'Google Compute Engine metadata service',
|
||||||
|
failure, email)
|
||||||
|
return self._service_account_email
|
||||||
|
|||||||
@@ -320,10 +320,27 @@ class ServiceAccountCredentials(AssertionCredentials):
|
|||||||
key_id=self._private_key_id)
|
key_id=self._private_key_id)
|
||||||
|
|
||||||
def sign_blob(self, blob):
|
def sign_blob(self, blob):
|
||||||
|
"""Cryptographically sign a blob (of bytes).
|
||||||
|
|
||||||
|
Implements abstract method
|
||||||
|
:meth:`oauth2client.client.AssertionCredentials.sign_blob`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blob: bytes, Message to be signed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple, A pair of the private key ID used to sign the blob and
|
||||||
|
the signed contents.
|
||||||
|
"""
|
||||||
return self._private_key_id, self._signer.sign(blob)
|
return self._private_key_id, self._signer.sign(blob)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def service_account_email(self):
|
def service_account_email(self):
|
||||||
|
"""Get the email for the current service account.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
string, The email associated with the service account.
|
||||||
|
"""
|
||||||
return self._service_account_email
|
return self._service_account_email
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -116,14 +116,29 @@ class TestAppAssertionCredentials(unittest.TestCase):
|
|||||||
|
|
||||||
class AppIdentityStubImpl(apiproxy_stub.APIProxyStub):
|
class AppIdentityStubImpl(apiproxy_stub.APIProxyStub):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, key_name=None, sig_bytes=None,
|
||||||
|
svc_acct=None):
|
||||||
super(TestAppAssertionCredentials.AppIdentityStubImpl,
|
super(TestAppAssertionCredentials.AppIdentityStubImpl,
|
||||||
self).__init__('app_identity_service')
|
self).__init__('app_identity_service')
|
||||||
|
self._key_name = key_name
|
||||||
|
self._sig_bytes = sig_bytes
|
||||||
|
self._sign_calls = []
|
||||||
|
self._svc_acct = svc_acct
|
||||||
|
self._get_acct_name_calls = 0
|
||||||
|
|
||||||
def _Dynamic_GetAccessToken(self, request, response):
|
def _Dynamic_GetAccessToken(self, request, response):
|
||||||
response.set_access_token('a_token_123')
|
response.set_access_token('a_token_123')
|
||||||
response.set_expiration_time(time.time() + 1800)
|
response.set_expiration_time(time.time() + 1800)
|
||||||
|
|
||||||
|
def _Dynamic_SignForApp(self, request, response):
|
||||||
|
response.set_key_name(self._key_name)
|
||||||
|
response.set_signature_bytes(self._sig_bytes)
|
||||||
|
self._sign_calls.append(request.bytes_to_sign())
|
||||||
|
|
||||||
|
def _Dynamic_GetServiceAccountName(self, request, response):
|
||||||
|
response.set_service_account_name(self._svc_acct)
|
||||||
|
self._get_acct_name_calls += 1
|
||||||
|
|
||||||
class ErroringAppIdentityStubImpl(apiproxy_stub.APIProxyStub):
|
class ErroringAppIdentityStubImpl(apiproxy_stub.APIProxyStub):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -210,6 +225,49 @@ class TestAppAssertionCredentials(unittest.TestCase):
|
|||||||
self.assertTrue(isinstance(new_credentials, AppAssertionCredentials))
|
self.assertTrue(isinstance(new_credentials, AppAssertionCredentials))
|
||||||
self.assertEqual('dummy_scope', new_credentials.scope)
|
self.assertEqual('dummy_scope', new_credentials.scope)
|
||||||
|
|
||||||
|
def test_sign_blob(self):
|
||||||
|
key_name = b'1234567890'
|
||||||
|
sig_bytes = b'himom'
|
||||||
|
app_identity_stub = self.AppIdentityStubImpl(
|
||||||
|
key_name=key_name, sig_bytes=sig_bytes)
|
||||||
|
apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
|
||||||
|
apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
|
||||||
|
app_identity_stub)
|
||||||
|
credentials = AppAssertionCredentials([])
|
||||||
|
to_sign = b'blob'
|
||||||
|
self.assertEqual(app_identity_stub._sign_calls, [])
|
||||||
|
result = credentials.sign_blob(to_sign)
|
||||||
|
self.assertEqual(result, (key_name, sig_bytes))
|
||||||
|
self.assertEqual(app_identity_stub._sign_calls, [to_sign])
|
||||||
|
|
||||||
|
def test_service_account_email(self):
|
||||||
|
acct_name = 'new-value@appspot.gserviceaccount.com'
|
||||||
|
app_identity_stub = self.AppIdentityStubImpl(svc_acct=acct_name)
|
||||||
|
apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
|
||||||
|
apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
|
||||||
|
app_identity_stub)
|
||||||
|
|
||||||
|
credentials = AppAssertionCredentials([])
|
||||||
|
self.assertIsNone(credentials._service_account_email)
|
||||||
|
self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
|
||||||
|
self.assertEqual(credentials.service_account_email, acct_name)
|
||||||
|
self.assertIsNotNone(credentials._service_account_email)
|
||||||
|
self.assertEqual(app_identity_stub._get_acct_name_calls, 1)
|
||||||
|
|
||||||
|
def test_service_account_email_already_set(self):
|
||||||
|
acct_name = 'existing@appspot.gserviceaccount.com'
|
||||||
|
credentials = AppAssertionCredentials([])
|
||||||
|
credentials._service_account_email = acct_name
|
||||||
|
|
||||||
|
app_identity_stub = self.AppIdentityStubImpl(svc_acct=acct_name)
|
||||||
|
apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
|
||||||
|
apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
|
||||||
|
app_identity_stub)
|
||||||
|
|
||||||
|
self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
|
||||||
|
self.assertEqual(credentials.service_account_email, acct_name)
|
||||||
|
self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
|
||||||
|
|
||||||
def test_get_access_token(self):
|
def test_get_access_token(self):
|
||||||
app_identity_stub = self.AppIdentityStubImpl()
|
app_identity_stub = self.AppIdentityStubImpl()
|
||||||
apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
|
apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
|
||||||
|
|||||||
@@ -17,14 +17,17 @@
|
|||||||
import json
|
import json
|
||||||
from six.moves import http_client
|
from six.moves import http_client
|
||||||
from six.moves import urllib
|
from six.moves import urllib
|
||||||
import unittest
|
import unittest2
|
||||||
|
|
||||||
import mock
|
import mock
|
||||||
|
|
||||||
|
import httplib2
|
||||||
from oauth2client._helpers import _to_bytes
|
from oauth2client._helpers import _to_bytes
|
||||||
from oauth2client.client import AccessTokenRefreshError
|
from oauth2client.client import AccessTokenRefreshError
|
||||||
from oauth2client.client import Credentials
|
from oauth2client.client import Credentials
|
||||||
from oauth2client.client import save_to_well_known_file
|
from oauth2client.client import save_to_well_known_file
|
||||||
|
from oauth2client.contrib.gce import _DEFAULT_EMAIL_METADATA
|
||||||
|
from oauth2client.contrib.gce import _get_service_account_email
|
||||||
from oauth2client.contrib.gce import _SCOPES_WARNING
|
from oauth2client.contrib.gce import _SCOPES_WARNING
|
||||||
from oauth2client.contrib.gce import AppAssertionCredentials
|
from oauth2client.contrib.gce import AppAssertionCredentials
|
||||||
|
|
||||||
@@ -32,7 +35,7 @@ from oauth2client.contrib.gce import AppAssertionCredentials
|
|||||||
__author__ = 'jcgregorio@google.com (Joe Gregorio)'
|
__author__ = 'jcgregorio@google.com (Joe Gregorio)'
|
||||||
|
|
||||||
|
|
||||||
class AppAssertionCredentialsTests(unittest.TestCase):
|
class AppAssertionCredentialsTests(unittest2.TestCase):
|
||||||
|
|
||||||
def test_constructor(self):
|
def test_constructor(self):
|
||||||
credentials = AppAssertionCredentials(foo='bar')
|
credentials = AppAssertionCredentials(foo='bar')
|
||||||
@@ -150,6 +153,49 @@ class AppAssertionCredentialsTests(unittest.TestCase):
|
|||||||
self.assertEqual('dummy_scope', new_credentials.scope)
|
self.assertEqual('dummy_scope', new_credentials.scope)
|
||||||
warn_mock.assert_called_once_with(_SCOPES_WARNING)
|
warn_mock.assert_called_once_with(_SCOPES_WARNING)
|
||||||
|
|
||||||
|
def test_sign_blob_not_implemented(self):
|
||||||
|
credentials = AppAssertionCredentials([])
|
||||||
|
with self.assertRaises(NotImplementedError):
|
||||||
|
credentials.sign_blob(b'blob')
|
||||||
|
|
||||||
|
@mock.patch('oauth2client.contrib.gce._get_service_account_email',
|
||||||
|
return_value=(None, 'retrieved@email.com'))
|
||||||
|
def test_service_account_email(self, get_email):
|
||||||
|
credentials = AppAssertionCredentials([])
|
||||||
|
self.assertIsNone(credentials._service_account_email)
|
||||||
|
self.assertEqual(credentials.service_account_email,
|
||||||
|
get_email.return_value[1])
|
||||||
|
self.assertIsNotNone(credentials._service_account_email)
|
||||||
|
get_email.assert_called_once_with()
|
||||||
|
|
||||||
|
@mock.patch('oauth2client.contrib.gce._get_service_account_email')
|
||||||
|
def test_service_account_email_already_set(self, get_email):
|
||||||
|
credentials = AppAssertionCredentials([])
|
||||||
|
acct_name = 'existing@email.com'
|
||||||
|
credentials._service_account_email = acct_name
|
||||||
|
self.assertEqual(credentials.service_account_email, acct_name)
|
||||||
|
get_email.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch('oauth2client.contrib.gce._get_service_account_email')
|
||||||
|
def test_service_account_email_failure(self, get_email):
|
||||||
|
# Set-up the mock.
|
||||||
|
bad_response = httplib2.Response({'status': http_client.NOT_FOUND})
|
||||||
|
content = b'bad-bytes-nothing-here'
|
||||||
|
get_email.return_value = (bad_response, content)
|
||||||
|
# Test the failure.
|
||||||
|
credentials = AppAssertionCredentials([])
|
||||||
|
self.assertIsNone(credentials._service_account_email)
|
||||||
|
with self.assertRaises(AttributeError) as exc_manager:
|
||||||
|
getattr(credentials, 'service_account_email')
|
||||||
|
|
||||||
|
error_msg = ('Failed to retrieve the email from the '
|
||||||
|
'Google Compute Engine metadata service')
|
||||||
|
self.assertEqual(
|
||||||
|
exc_manager.exception.args,
|
||||||
|
(error_msg, bad_response, content))
|
||||||
|
self.assertIsNone(credentials._service_account_email)
|
||||||
|
get_email.assert_called_once_with()
|
||||||
|
|
||||||
def test_get_access_token(self):
|
def test_get_access_token(self):
|
||||||
http = mock.MagicMock()
|
http = mock.MagicMock()
|
||||||
http.request = mock.MagicMock(
|
http.request = mock.MagicMock(
|
||||||
@@ -178,5 +224,43 @@ class AppAssertionCredentialsTests(unittest.TestCase):
|
|||||||
os.path.isdir = ORIGINAL_ISDIR
|
os.path.isdir = ORIGINAL_ISDIR
|
||||||
|
|
||||||
|
|
||||||
|
class Test__get_service_account_email(unittest2.TestCase):
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
http_request = mock.MagicMock()
|
||||||
|
acct_name = b'1234567890@developer.gserviceaccount.com'
|
||||||
|
http_request.return_value = (
|
||||||
|
httplib2.Response({'status': http_client.OK}), acct_name)
|
||||||
|
result = _get_service_account_email(http_request)
|
||||||
|
self.assertEqual(result, (None, acct_name.decode('utf-8')))
|
||||||
|
http_request.assert_called_once_with(
|
||||||
|
_DEFAULT_EMAIL_METADATA,
|
||||||
|
headers={'Metadata-Flavor': 'Google'})
|
||||||
|
|
||||||
|
@mock.patch.object(httplib2.Http, 'request')
|
||||||
|
def test_success_default_http(self, http_request):
|
||||||
|
# Don't make _from_bytes() work too hard.
|
||||||
|
acct_name = u'1234567890@developer.gserviceaccount.com'
|
||||||
|
http_request.return_value = (
|
||||||
|
httplib2.Response({'status': http_client.OK}), acct_name)
|
||||||
|
result = _get_service_account_email()
|
||||||
|
self.assertEqual(result, (None, acct_name))
|
||||||
|
http_request.assert_called_once_with(
|
||||||
|
_DEFAULT_EMAIL_METADATA,
|
||||||
|
headers={'Metadata-Flavor': 'Google'})
|
||||||
|
|
||||||
|
def test_failure(self):
|
||||||
|
http_request = mock.MagicMock()
|
||||||
|
response = httplib2.Response({'status': http_client.NOT_FOUND})
|
||||||
|
content = b'Not found'
|
||||||
|
http_request.return_value = (response, content)
|
||||||
|
result = _get_service_account_email(http_request)
|
||||||
|
|
||||||
|
self.assertEqual(result, (response, content))
|
||||||
|
http_request.assert_called_once_with(
|
||||||
|
_DEFAULT_EMAIL_METADATA,
|
||||||
|
headers={'Metadata-Flavor': 'Google'})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__': # pragma: NO COVER
|
if __name__ == '__main__': # pragma: NO COVER
|
||||||
unittest.main()
|
unittest2.main()
|
||||||
|
|||||||
@@ -1075,6 +1075,11 @@ class TestAssertionCredentials(unittest2.TestCase):
|
|||||||
self, '400', revoke_raise=True,
|
self, '400', revoke_raise=True,
|
||||||
valid_bool_value=False, token_attr='access_token')
|
valid_bool_value=False, token_attr='access_token')
|
||||||
|
|
||||||
|
def test_sign_blob_abstract(self):
|
||||||
|
credentials = AssertionCredentials(None)
|
||||||
|
with self.assertRaises(NotImplementedError):
|
||||||
|
credentials.sign_blob(b'blob')
|
||||||
|
|
||||||
|
|
||||||
class UpdateQueryParamsTest(unittest2.TestCase):
|
class UpdateQueryParamsTest(unittest2.TestCase):
|
||||||
def test_update_query_params_no_params(self):
|
def test_update_query_params_no_params(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user