Adding a .revoke() to Credentials. Closes issue 98.

Reviewed in https://codereview.appspot.com/7033052/
diff --git a/tests/data/client_secrets.json b/tests/data/client_secrets.json
index dee5c6e..fd96a7a 100644
--- a/tests/data/client_secrets.json
+++ b/tests/data/client_secrets.json
@@ -4,6 +4,7 @@
     "client_secret": "foo_client_secret",
     "redirect_uris": [],
     "auth_uri": "https://accounts.google.com/o/oauth2/auth",
-    "token_uri": "https://accounts.google.com/o/oauth2/token"
+    "token_uri": "https://accounts.google.com/o/oauth2/token",
+    "revoke_uri": "https://accounts.google.com/o/oauth2/revoke"
   }
 }
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 2c28cd2..1c8b706 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -57,6 +57,7 @@
 from apiclient.http import MediaUpload
 from apiclient.http import MediaUploadProgress
 from apiclient.http import tunnel_patch
+from oauth2client import GOOGLE_TOKEN_URI
 from oauth2client.anyjson import simplejson
 from oauth2client.client import OAuth2Credentials
 import uritemplate
@@ -896,14 +897,12 @@
     client_secret = 'cOuDdkfjxxnv+'
     refresh_token = '1/0/a.df219fjls0'
     token_expiry = datetime.datetime.utcnow()
-    token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
     user_agent = 'refresh_checker/1.0'
     return OAuth2Credentials(
         access_token, client_id, client_secret,
-        refresh_token, token_expiry, token_uri,
+        refresh_token, token_expiry, GOOGLE_TOKEN_URI,
         user_agent)
 
-
   def test_pickle_with_credentials(self):
     credentials = self._dummy_token()
     http = self._dummy_zoo_request()
diff --git a/tests/test_oauth2client.py b/tests/test_oauth2client.py
index 37e69ea..6dc2729 100644
--- a/tests/test_oauth2client.py
+++ b/tests/test_oauth2client.py
@@ -29,13 +29,10 @@
 import unittest
 import urlparse
 
-try:
-    from urlparse import parse_qs
-except ImportError:
-    from cgi import parse_qs
-
 from apiclient.http import HttpMock
 from apiclient.http import HttpMockSequence
+from oauth2client import GOOGLE_REVOKE_URI
+from oauth2client import GOOGLE_TOKEN_URI
 from oauth2client.anyjson import simplejson
 from oauth2client.client import AccessTokenCredentials
 from oauth2client.client import AccessTokenCredentialsError
@@ -49,12 +46,17 @@
 from oauth2client.client import OAuth2WebServerFlow
 from oauth2client.client import OOB_CALLBACK_URN
 from oauth2client.client import REFRESH_STATUS_CODES
+from oauth2client.client import Storage
+from oauth2client.client import TokenRevokeError
 from oauth2client.client import VerifyJwtTokenError
 from oauth2client.client import _extract_id_token
+from oauth2client.client import _update_query_params
 from oauth2client.client import credentials_from_clientsecrets_and_code
 from oauth2client.client import credentials_from_code
 from oauth2client.client import flow_from_clientsecrets
 from oauth2client.clientsecrets import _loadfile
+from test_discovery import assertUrisEqual
+
 
 DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
 
@@ -89,20 +91,54 @@
     restored = Credentials.new_from_json(json)
 
 
+class DummyDeleteStorage(Storage):
+  delete_called = False
+
+  def locked_delete(self):
+    self.delete_called = True
+
+
+def _token_revoke_test_helper(testcase, status, revoke_raise,
+                              valid_bool_value, token_attr):
+  current_store = getattr(testcase.credentials, 'store', None)
+
+  dummy_store = DummyDeleteStorage()
+  testcase.credentials.set_store(dummy_store)
+
+  actual_do_revoke = testcase.credentials._do_revoke
+  testcase.token_from_revoke = None
+  def do_revoke_stub(http_request, token):
+    testcase.token_from_revoke = token
+    return actual_do_revoke(http_request, token)
+  testcase.credentials._do_revoke = do_revoke_stub
+
+  http = HttpMock(headers={'status': status})
+  if revoke_raise:
+    testcase.assertRaises(TokenRevokeError, testcase.credentials.revoke, http)
+  else:
+    testcase.credentials.revoke(http)
+
+  testcase.assertEqual(getattr(testcase.credentials, token_attr),
+                       testcase.token_from_revoke)
+  testcase.assertEqual(valid_bool_value, testcase.credentials.invalid)
+  testcase.assertEqual(valid_bool_value, dummy_store.delete_called)
+
+  testcase.credentials.set_store(current_store)
+
+
 class BasicCredentialsTests(unittest.TestCase):
 
   def setUp(self):
-    access_token = "foo"
-    client_id = "some_client_id"
-    client_secret = "cOuDdkfjxxnv+"
-    refresh_token = "1/0/a.df219fjls0"
+    access_token = 'foo'
+    client_id = 'some_client_id'
+    client_secret = 'cOuDdkfjxxnv+'
+    refresh_token = '1/0/a.df219fjls0'
     token_expiry = datetime.datetime.utcnow()
-    token_uri = "https://www.google.com/accounts/o8/oauth2/token"
-    user_agent = "refresh_checker/1.0"
+    user_agent = 'refresh_checker/1.0'
     self.credentials = OAuth2Credentials(
-      access_token, client_id, client_secret,
-      refresh_token, token_expiry, token_uri,
-      user_agent)
+        access_token, client_id, client_secret,
+        refresh_token, token_expiry, GOOGLE_TOKEN_URI,
+        user_agent, revoke_uri=GOOGLE_REVOKE_URI)
 
   def test_token_refresh_success(self):
     for status_code in REFRESH_STATUS_CODES:
@@ -112,7 +148,7 @@
         ({'status': '200'}, 'echo_request_headers'),
         ])
       http = self.credentials.authorize(http)
-      resp, content = http.request("http://example.com")
+      resp, content = http.request('http://example.com')
       self.assertEqual('Bearer 1/3w', content['Authorization'])
       self.assertFalse(self.credentials.access_token_expired)
 
@@ -124,18 +160,28 @@
         ])
       http = self.credentials.authorize(http)
       try:
-        http.request("http://example.com")
-        self.fail("should raise AccessTokenRefreshError exception")
+        http.request('http://example.com')
+        self.fail('should raise AccessTokenRefreshError exception')
       except AccessTokenRefreshError:
         pass
       self.assertTrue(self.credentials.access_token_expired)
 
+  def test_token_revoke_success(self):
+    _token_revoke_test_helper(
+        self, '200', revoke_raise=False,
+        valid_bool_value=True, token_attr='refresh_token')
+
+  def test_token_revoke_failure(self):
+    _token_revoke_test_helper(
+        self, '400', revoke_raise=True,
+        valid_bool_value=False, token_attr='refresh_token')
+
   def test_non_401_error_response(self):
     http = HttpMockSequence([
       ({'status': '400'}, ''),
       ])
     http = self.credentials.authorize(http)
-    resp, content = http.request("http://example.com")
+    resp, content = http.request('http://example.com')
     self.assertEqual(400, resp.status)
 
   def test_to_from_json(self):
@@ -153,17 +199,16 @@
     client_secret = u'cOuDdkfjxxnv+'
     refresh_token = u'1/0/a.df219fjls0'
     token_expiry = unicode(datetime.datetime.utcnow())
-    token_uri = u'https://www.google.com/accounts/o8/oauth2/token'
+    token_uri = unicode(GOOGLE_TOKEN_URI)
+    revoke_uri = unicode(GOOGLE_REVOKE_URI)
     user_agent = u'refresh_checker/1.0'
     credentials = OAuth2Credentials(access_token, client_id, client_secret,
                                     refresh_token, token_expiry, token_uri,
-                                    user_agent)
+                                    user_agent, revoke_uri=revoke_uri)
 
     http = HttpMock(headers={'status': '200'})
     http = credentials.authorize(http)
-    http.request(u'http://example.com', method=u'GET', headers={
-        u'foo': u'bar'
-        })
+    http.request(u'http://example.com', method=u'GET', headers={u'foo': u'bar'})
     for k, v in http.headers.iteritems():
       self.assertEqual(str, type(k))
       self.assertEqual(str, type(v))
@@ -180,9 +225,10 @@
 class AccessTokenCredentialsTests(unittest.TestCase):
 
   def setUp(self):
-    access_token = "foo"
-    user_agent = "refresh_checker/1.0"
-    self.credentials = AccessTokenCredentials(access_token, user_agent)
+    access_token = 'foo'
+    user_agent = 'refresh_checker/1.0'
+    self.credentials = AccessTokenCredentials(access_token, user_agent,
+                                              revoke_uri=GOOGLE_REVOKE_URI)
 
   def test_token_refresh_success(self):
     for status_code in REFRESH_STATUS_CODES:
@@ -191,12 +237,22 @@
         ])
       http = self.credentials.authorize(http)
       try:
-        resp, content = http.request("http://example.com")
-        self.fail("should throw exception if token expires")
+        resp, content = http.request('http://example.com')
+        self.fail('should throw exception if token expires')
       except AccessTokenCredentialsError:
         pass
       except Exception:
-        self.fail("should only throw AccessTokenCredentialsError")
+        self.fail('should only throw AccessTokenCredentialsError')
+
+  def test_token_revoke_success(self):
+    _token_revoke_test_helper(
+        self, '200', revoke_raise=False,
+        valid_bool_value=True, token_attr='access_token')
+
+  def test_token_revoke_failure(self):
+    _token_revoke_test_helper(
+        self, '400', revoke_raise=True,
+        valid_bool_value=False, token_attr='access_token')
 
   def test_non_401_error_response(self):
     http = HttpMockSequence([
@@ -216,8 +272,8 @@
 
 
 class TestAssertionCredentials(unittest.TestCase):
-  assertion_text = "This is the assertion"
-  assertion_type = "http://www.google.com/assertionType"
+  assertion_text = 'This is the assertion'
+  assertion_type = 'http://www.google.com/assertionType'
 
   class AssertionCredentialsTestImpl(AssertionCredentials):
 
@@ -225,7 +281,7 @@
       return TestAssertionCredentials.assertion_text
 
   def setUp(self):
-    user_agent = "fun/2.0"
+    user_agent = 'fun/2.0'
     self.credentials = self.AssertionCredentialsTestImpl(self.assertion_type,
         user_agent=user_agent)
 
@@ -240,11 +296,34 @@
       ({'status': '200'}, 'echo_request_headers'),
       ])
     http = self.credentials.authorize(http)
-    resp, content = http.request("http://example.com")
+    resp, content = http.request('http://example.com')
     self.assertEqual('Bearer 1/3w', content['Authorization'])
 
+  def test_token_revoke_success(self):
+    _token_revoke_test_helper(
+        self, '200', revoke_raise=False,
+        valid_bool_value=True, token_attr='access_token')
 
-class ExtractIdTokenText(unittest.TestCase):
+  def test_token_revoke_failure(self):
+    _token_revoke_test_helper(
+        self, '400', revoke_raise=True,
+        valid_bool_value=False, token_attr='access_token')
+
+
+class UpdateQueryParamsTest(unittest.TestCase):
+  def test_update_query_params_no_params(self):
+    uri = 'http://www.google.com'
+    updated = _update_query_params(uri, {'a': 'b'})
+    self.assertEqual(updated, uri + '?a=b')
+
+  def test_update_query_params_existing_params(self):
+    uri = 'http://www.google.com?x=y'
+    updated = _update_query_params(uri, {'a': 'b', 'c': 'd&'})
+    hardcoded_update = uri + '&a=b&c=d%26'
+    assertUrisEqual(self, updated, hardcoded_update)
+
+
+class ExtractIdTokenTest(unittest.TestCase):
   """Tests _extract_id_token()."""
 
   def test_extract_success(self):
@@ -272,13 +351,14 @@
         scope='foo',
         redirect_uri=OOB_CALLBACK_URN,
         user_agent='unittest-sample/1.0',
+        revoke_uri='dummy_revoke_uri',
         )
 
   def test_construct_authorize_url(self):
     authorize_url = self.flow.step1_get_authorize_url()
 
     parsed = urlparse.urlparse(authorize_url)
-    q = parse_qs(parsed[4])
+    q = urlparse.parse_qs(parsed[4])
     self.assertEqual('client_id+1', q['client_id'][0])
     self.assertEqual('code', q['response_type'][0])
     self.assertEqual('foo', q['scope'][0])
@@ -299,7 +379,7 @@
     authorize_url = flow.step1_get_authorize_url()
 
     parsed = urlparse.urlparse(authorize_url)
-    q = parse_qs(parsed[4])
+    q = urlparse.parse_qs(parsed[4])
     self.assertEqual('client_id+1', q['client_id'][0])
     self.assertEqual('token', q['response_type'][0])
     self.assertEqual('foo', q['scope'][0])
@@ -313,23 +393,23 @@
 
     try:
       credentials = self.flow.step2_exchange('some random code', http=http)
-      self.fail("should raise exception if exchange doesn't get 200")
+      self.fail('should raise exception if exchange doesn\'t get 200')
     except FlowExchangeError:
       pass
 
   def test_urlencoded_exchange_failure(self):
     http = HttpMockSequence([
-      ({'status': '400'}, "error=invalid_request"),
+      ({'status': '400'}, 'error=invalid_request'),
     ])
 
     try:
       credentials = self.flow.step2_exchange('some random code', http=http)
-      self.fail("should raise exception if exchange doesn't get 200")
+      self.fail('should raise exception if exchange doesn\'t get 200')
     except FlowExchangeError, e:
       self.assertEquals('invalid_request', str(e))
 
   def test_exchange_failure_with_json_error(self):
-    # Some providers have "error" attribute as a JSON object
+    # Some providers have 'error' attribute as a JSON object
     # in place of regular string.
     # This test makes sure no strange object-to-string coversion
     # exceptions are being raised instead of FlowExchangeError.
@@ -342,7 +422,7 @@
 
     try:
       credentials = self.flow.step2_exchange('some random code', http=http)
-      self.fail("should raise exception if exchange doesn't get 200")
+      self.fail('should raise exception if exchange doesn\'t get 200')
     except FlowExchangeError, e:
       pass
 
@@ -358,10 +438,11 @@
     self.assertEqual('SlAV32hkKG', credentials.access_token)
     self.assertNotEqual(None, credentials.token_expiry)
     self.assertEqual('8xLOxBtZp8', credentials.refresh_token)
+    self.assertEqual('dummy_revoke_uri', credentials.revoke_uri)
 
   def test_urlencoded_exchange_success(self):
     http = HttpMockSequence([
-      ({'status': '200'}, "access_token=SlAV32hkKG&expires_in=3600"),
+      ({'status': '200'}, 'access_token=SlAV32hkKG&expires_in=3600'),
     ])
 
     credentials = self.flow.step2_exchange('some random code', http=http)
@@ -370,9 +451,9 @@
 
   def test_urlencoded_expires_param(self):
     http = HttpMockSequence([
-      # Note the "expires=3600" where you'd normally
-      # have if named "expires_in"
-      ({'status': '200'}, "access_token=SlAV32hkKG&expires=3600"),
+      # Note the 'expires=3600' where you'd normally
+      # have if named 'expires_in'
+      ({'status': '200'}, 'access_token=SlAV32hkKG&expires=3600'),
     ])
 
     credentials = self.flow.step2_exchange('some random code', http=http)
@@ -391,7 +472,7 @@
     http = HttpMockSequence([
       # This might be redundant but just to make sure
       # urlencoded access_token gets parsed correctly
-      ({'status': '200'}, "access_token=SlAV32hkKG"),
+      ({'status': '200'}, 'access_token=SlAV32hkKG'),
     ])
 
     credentials = self.flow.step2_exchange('some random code', http=http)
@@ -456,15 +537,15 @@
     self.redirect_uri = 'postmessage'
 
   def test_exchange_code_for_token(self):
+    token = 'asdfghjkl'
+    payload =simplejson.dumps({'access_token': token, 'expires_in': 3600})
     http = HttpMockSequence([
-      ({'status': '200'},
-      """{ "access_token":"asdfghjkl",
-       "expires_in":3600 }"""),
+      ({'status': '200'}, payload),
     ])
     credentials = credentials_from_code(self.client_id, self.client_secret,
         self.scope, self.code, redirect_uri=self.redirect_uri,
         http=http)
-    self.assertEquals(credentials.access_token, 'asdfghjkl')
+    self.assertEquals(credentials.access_token, token)
     self.assertNotEqual(None, credentials.token_expiry)
 
   def test_exchange_code_for_token_fail(self):
@@ -476,7 +557,7 @@
       credentials = credentials_from_code(self.client_id, self.client_secret,
           self.scope, self.code, redirect_uri=self.redirect_uri,
           http=http)
-      self.fail("should raise exception if exchange doesn't get 200")
+      self.fail('should raise exception if exchange doesn\'t get 200')
     except FlowExchangeError:
       pass
 
@@ -500,8 +581,8 @@
     load_and_cache('client_secrets.json', 'some_secrets', cache_mock)
 
     credentials = credentials_from_clientsecrets_and_code(
-                            'some_secrets', self.scope,
-                            self.code, http=http, cache=cache_mock)
+        'some_secrets', self.scope,
+        self.code, http=http, cache=cache_mock)
     self.assertEquals(credentials.access_token, 'asdfghjkl')
 
   def test_exchange_code_and_file_for_token_fail(self):
@@ -513,7 +594,7 @@
       credentials = credentials_from_clientsecrets_and_code(
                             datafile('client_secrets.json'), self.scope,
                             self.code, http=http)
-      self.fail("should raise exception if exchange doesn't get 200")
+      self.fail('should raise exception if exchange doesn\'t get 200')
     except FlowExchangeError:
       pass
 
diff --git a/tests/test_oauth2client_appengine.py b/tests/test_oauth2client_appengine.py
index 870b5a8..2d95f08 100644
--- a/tests/test_oauth2client_appengine.py
+++ b/tests/test_oauth2client_appengine.py
@@ -52,6 +52,7 @@
 from google.appengine.ext import testbed
 from google.appengine.runtime import apiproxy_errors
 from oauth2client import appengine
+from oauth2client import GOOGLE_TOKEN_URI
 from oauth2client.anyjson import simplejson
 from oauth2client.clientsecrets import _loadfile
 from oauth2client.clientsecrets import InvalidClientSecretsError
@@ -156,12 +157,12 @@
   def test_raise_correct_type_of_exception(self):
     app_identity_stub = self.ErroringAppIdentityStubImpl()
     apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
-    apiproxy_stub_map.apiproxy.RegisterStub("app_identity_service",
+    apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
                                             app_identity_stub)
     apiproxy_stub_map.apiproxy.RegisterStub(
       'memcache', memcache_stub.MemcacheServiceStub())
 
-    scope = "http://www.googleapis.com/scope"
+    scope = 'http://www.googleapis.com/scope'
     try:
       credentials = AppAssertionCredentials(scope)
       http = httplib2.Http()
@@ -271,16 +272,15 @@
     self.testbed.init_memcache_stub()
     self.testbed.init_user_stub()
 
-    access_token = "foo"
-    client_id = "some_client_id"
-    client_secret = "cOuDdkfjxxnv+"
-    refresh_token = "1/0/a.df219fjls0"
+    access_token = 'foo'
+    client_id = 'some_client_id'
+    client_secret = 'cOuDdkfjxxnv+'
+    refresh_token = '1/0/a.df219fjls0'
     token_expiry = datetime.datetime.utcnow()
-    token_uri = "https://www.google.com/accounts/o8/oauth2/token"
-    user_agent = "refresh_checker/1.0"
+    user_agent = 'refresh_checker/1.0'
     self.credentials = OAuth2Credentials(
       access_token, client_id, client_secret,
-      refresh_token, token_expiry, token_uri,
+      refresh_token, token_expiry, GOOGLE_TOKEN_URI,
       user_agent)
 
   def tearDown(self):
@@ -489,7 +489,7 @@
     self.assertEqual(False, self.decorator.has_credentials())
 
     m = mox.Mox()
-    m.StubOutWithMock(appengine, "_parse_state_value")
+    m.StubOutWithMock(appengine, '_parse_state_value')
     appengine._parse_state_value('foo_path:xsrfkey123',
                        mox.IgnoreArg()).AndReturn('foo_path')
     m.ReplayAll()
@@ -531,7 +531,7 @@
     self.assertTrue(response.status.startswith('302'))
 
     m = mox.Mox()
-    m.StubOutWithMock(appengine, "_parse_state_value")
+    m.StubOutWithMock(appengine, '_parse_state_value')
     appengine._parse_state_value('foo_path:xsrfkey123',
                        mox.IgnoreArg()).AndReturn('foo_path')
     m.ReplayAll()
@@ -573,7 +573,7 @@
     self.assertEqual('code', q['response_type'][0])
 
     m = mox.Mox()
-    m.StubOutWithMock(appengine, "_parse_state_value")
+    m.StubOutWithMock(appengine, '_parse_state_value')
     appengine._parse_state_value('bar_path:xsrfkey456',
                        mox.IgnoreArg()).AndReturn('bar_path')
     m.ReplayAll()
@@ -616,7 +616,8 @@
         user_agent='foo_user_agent',
         scope=['foo_scope', 'bar_scope'],
         access_type='offline',
-        approval_prompt='force')
+        approval_prompt='force',
+        revoke_uri='dummy_revoke_uri')
     request_handler = MockRequestHandler()
     decorator._create_flow(request_handler)
 
@@ -625,6 +626,7 @@
     self.assertEqual('offline', decorator.flow.params['access_type'])
     self.assertEqual('force', decorator.flow.params['approval_prompt'])
     self.assertEqual('foo_user_agent', decorator.flow.user_agent)
+    self.assertEqual('dummy_revoke_uri', decorator.flow.revoke_uri)
     self.assertEqual(None, decorator.flow.params.get('user_agent', None))
 
   def test_decorator_from_client_secrets(self):
@@ -639,6 +641,12 @@
     http = self.decorator.http()
     self.assertEquals('foo_access_token', http.request.credentials.access_token)
 
+    # revoke_uri is not required
+    self.assertEqual(self.decorator._revoke_uri,
+                     'https://accounts.google.com/o/oauth2/revoke')
+    self.assertEqual(self.decorator._revoke_uri,
+                     self.decorator.credentials.revoke_uri)
+
   def test_decorator_from_cached_client_secrets(self):
     cache_mock = CacheMock()
     load_and_cache('client_secrets.json', 'secret', cache_mock)
diff --git a/tests/test_oauth2client_file.py b/tests/test_oauth2client_file.py
index 07e7608..c954d5e 100644
--- a/tests/test_oauth2client_file.py
+++ b/tests/test_oauth2client_file.py
@@ -32,6 +32,7 @@
 import unittest
 
 from apiclient.http import HttpMockSequence
+from oauth2client import GOOGLE_TOKEN_URI
 from oauth2client import file
 from oauth2client import locked_file
 from oauth2client import multistore_file
diff --git a/tests/test_oauth2client_keyring.py b/tests/test_oauth2client_keyring.py
index 6fb0b9e..e5b9971 100644
--- a/tests/test_oauth2client_keyring.py
+++ b/tests/test_oauth2client_keyring.py
@@ -25,6 +25,7 @@
 import unittest
 import mox
 
+from oauth2client import GOOGLE_TOKEN_URI
 from oauth2client.client import OAuth2Credentials
 from oauth2client.keyring_storage import Storage
 
@@ -65,12 +66,11 @@
     client_secret = 'cOuDdkfjxxnv+'
     refresh_token = '1/0/a.df219fjls0'
     token_expiry = datetime.datetime.utcnow()
-    token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
     user_agent = 'refresh_checker/1.0'
 
     credentials = OAuth2Credentials(
         access_token, client_id, client_secret,
-        refresh_token, token_expiry, token_uri,
+        refresh_token, token_expiry, GOOGLE_TOKEN_URI,
         user_agent)
 
     m = mox.Mox()