Merge lp:~leonardr/launchpad/oauth-doctest-to-unit-test into lp:launchpad

Proposed by Leonard Richardson on 2010-10-18
Status: Merged
Merged at revision: 11779
Proposed branch: lp:~leonardr/launchpad/oauth-doctest-to-unit-test
Merge into: lp:launchpad
Prerequisite: lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time
Diff against target: 892 lines (+396/-398)
4 files modified
lib/canonical/launchpad/database/oauth.py (+4/-2)
lib/canonical/launchpad/doc/oauth.txt (+6/-385)
lib/canonical/launchpad/tests/test_oauth_tokens.py (+374/-8)
lib/lp/testing/factory.py (+12/-3)
To merge this branch: bzr merge lp:~leonardr/launchpad/oauth-doctest-to-unit-test
Reviewer Review Type Date Requested Status
Henning Eggers (community) code 2010-10-18 Approve on 2010-10-18
Review via email: mp+38715@code.launchpad.net

Description of the Change

This branch makes one substantive change: when a request token is converted into an access token, the request token's .date_expires becomes the access token's .date_expires, just as the request token's .context becomes the access token's .context and the request token's .permission becomes the access token's .access_level. This is a follow-up to my automatically-calculate-request-token-expire-time branch, which freed up IOAuthRequestToken.date_expires for just this purpose.

The vast majority of this branch consists of converting the oauth.txt doctests into unit tests. I converted all the doctests except for the ones dealing with OAuth nonces.

I also added a new unit test (test_access_token_inherits_context_and_expiration) to test the new code.

To post a comment you must log in.
Henning Eggers (henninge) wrote :
Download full text (14.8 KiB)

Hi Leonard!
Thanks for the new unit tests! That is really cool stuff. I don't have much to
complain, I'd just like you to split up the tests a bit more. Having long test
methods with lots of asserts turns a unit test into a doc test again. Please
see my comments.

Cheers,
Henning

Am 18.10.2010 15:05, schrieb Leonard Richardson:
> === modified file 'lib/canonical/launchpad/database/oauth.py'

I have to trust you on this one, no formal glitches, though.

> === modified file 'lib/canonical/launchpad/doc/oauth.txt'
> --- lib/canonical/launchpad/doc/oauth.txt 2010-10-18 13:04:51 +0000
> +++ lib/canonical/launchpad/doc/oauth.txt 2010-10-18 13:04:52 +0000

[...]

> += OAuth =
> +
> +Most of the OAuth doctests have been converted into unit tests and
> +moved to test_oauth_tokens.py
> +

Are you sure this is necessary? Do think many people will be searching for
tests here and not look far a unittest? This is a common structure that should
not need to be explained.

The fact that the tests were *moved* is transitory but has no meaning for this
doctest.

> +== Nonces and timestamps ==
>
> A nonce is a random string, generated by the client for each request.
>

[...]

> === modified file 'lib/canonical/launchpad/tests/test_oauth_tokens.py'
> --- lib/canonical/launchpad/tests/test_oauth_tokens.py 2010-10-18 13:04:51 +0000
> +++ lib/canonical/launchpad/tests/test_oauth_tokens.py 2010-10-18 13:04:52 +0000
> @@ -1,35 +1,242 @@
> # Copyright 2010 Canonical Ltd. This software is licensed under the
> # GNU Affero General Public License version 3 (see the file LICENSE).
>
> +"""OAuth is a mechanism for allowing a user's desktop or a third-party
> +website to access Launchpad on a user's behalf. These applications
> +are identified by a unique key and are stored as OAuthConsumers. The
> +OAuth specification is defined in <http://oauth.net/core/1.0/>.
> +"""
> +
> from datetime import (
> datetime,
> timedelta
> )
> import pytz
>
> -from canonical.launchpad.webapp.interfaces import OAuthPermission
> +from zope.component import getUtility
> +from zope.proxy import sameProxiedObjects
> +from zope.security.interfaces import Unauthorized
> +
> +from canonical.launchpad.ftests import (
> + login_person,
> + logout,
> + )
> +from canonical.launchpad.webapp.interfaces import (
> + AccessLevel,
> + OAuthPermission,
> + )
> +from canonical.launchpad.webapp.testing import verifyObject
> from canonical.testing.layers import DatabaseFunctionalLayer

Wrong import ordering. Run the utilities/format-imports script on your branch,
please.

>
> +from canonical.launchpad.interfaces.oauth import (
> + IOAuthConsumer,
> + IOAuthConsumerSet,
> + IOAuthRequestToken,
> + IOAuthRequestTokenSet,
> + )
> +
> from lp.testing import (
> TestCaseWithFactory,
> + oauth_access_token_for
> )
>
>
> -class TestRequestTokens(TestCaseWithFactory):
> +class TestOAuth(TestCaseWithFactory):

Using TestCase base classes is a bit tricky. This one is safe because it does
not define any "test_" methods but usually it is a better idea to define
common functionality for test cases in a Mixin. Although in that case the
Mixin must not co...

review: Approve (code)
Leonard Richardson (leonardr) wrote :

I've split up the unit tests; please take another look and see if there's more you'd like me to do.

Henning Eggers (henninge) wrote :

The split-up looks good. I would have left the comments in the methods. Also, the convention for naming test methods is "test_methodName_condition_or_expected_result", at least where a specific method is being tested. Have a look if that fits for any of your cases.

Cheers, Henning

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/database/oauth.py'
2--- lib/canonical/launchpad/database/oauth.py 2010-10-19 18:51:49 +0000
3+++ lib/canonical/launchpad/database/oauth.py 2010-10-19 18:51:50 +0000
4@@ -310,7 +310,7 @@
5 expires = self.date_created + timedelta(hours=REQUEST_TOKEN_VALIDITY)
6 return expires <= now
7
8- def review(self, user, permission, context=None):
9+ def review(self, user, permission, context=None, date_expires=None):
10 """See `IOAuthRequestToken`."""
11 if self.is_reviewed:
12 raise AssertionError(
13@@ -320,6 +320,7 @@
14 'This request token has expired and can no longer be '
15 'reviewed.')
16 self.date_reviewed = datetime.now(pytz.timezone('UTC'))
17+ self.date_expires = date_expires
18 self.person = user
19 self.permission = permission
20 if IProduct.providedBy(context):
21@@ -352,7 +353,8 @@
22 access_level = AccessLevel.items[self.permission.name]
23 access_token = OAuthAccessToken(
24 consumer=self.consumer, person=self.person, key=key,
25- secret=secret, permission=access_level, product=self.product,
26+ secret=secret, permission=access_level,
27+ date_expires=self.date_expires, product=self.product,
28 project=self.project, distribution=self.distribution,
29 sourcepackagename=self.sourcepackagename)
30 self.destroySelf()
31
32=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
33--- lib/canonical/launchpad/doc/oauth.txt 2010-10-19 18:51:49 +0000
34+++ lib/canonical/launchpad/doc/oauth.txt 2010-10-19 18:51:50 +0000
35@@ -1,334 +1,9 @@
36-=====
37-OAuth
38-=====
39-
40-This is a mechanism for allowing a third party application to access
41-Launchpad on a user's behalf. These applications are identified by a
42-unique key and are stored as OAuthConsumers. The OAuth specification is
43-defined in <http://oauth.net/core/1.0/>.
44-
45-These applications (also called consumers) are managed by the
46-OAuthConsumerSet utility.
47-
48- >>> from canonical.launchpad.webapp.testing import verifyObject
49- >>> from canonical.launchpad.webapp.interfaces import (
50- ... AccessLevel, OAuthPermission)
51- >>> from canonical.launchpad.interfaces import (
52- ... IOAuthAccessToken, IOAuthConsumer, IOAuthConsumerSet,
53- ... IOAuthNonce, IOAuthRequestToken, IPersonSet)
54- >>> consumer_set = getUtility(IOAuthConsumerSet)
55- >>> verifyObject(IOAuthConsumerSet, consumer_set)
56- True
57-
58- >>> consumer = consumer_set.new(key='asdfg')
59- >>> verifyObject(IOAuthConsumer, consumer)
60- True
61-
62- >>> consumer_set.getByKey('asdfg') == consumer
63- True
64-
65- >>> print consumer_set.getByKey('gfdsa')
66- None
67-
68-As mentioned above, the keys are unique, so we can't create a second
69-Consumer with the same key.
70-
71- >>> consumer_set.new(key='asdfg')
72- Traceback (most recent call last):
73- ...
74- AssertionError: ...
75-
76-Desktop consumers
77-=================
78-
79-In a web context, each application is represented by a unique consumer
80-key. But a typical user sitting at a typical desktop (or other
81-personal computer), using multiple desktop applications that integrate
82-with Launchpad, is represented by a single consumer key. The user's
83-session as a whole is a single "consumer", and the consumer key is
84-expected to contain structured information: the type of system
85-(usually the operating system plus the word "desktop") and a string
86-that the end-user would recognize as identifying their computer.
87-
88- >>> desktop_key = consumer_set.new(
89- ... "System-wide: Ubuntu desktop (hostname)")
90- >>> desktop_key.is_integrated_desktop
91- True
92- >>> print desktop_key.integrated_desktop_type
93- Ubuntu desktop
94- >>> print desktop_key.integrated_desktop_name
95- hostname
96-
97- >>> desktop_key = consumer_set.new(
98- ... "System-wide: Android phone (My Phone)")
99- >>> desktop_key.is_integrated_desktop
100- True
101- >>> print desktop_key.integrated_desktop_type
102- Android phone
103- >>> print desktop_key.integrated_desktop_name
104- My Phone
105-
106-A normal OAuth consumer does not have this information.
107-
108- >>> ordinary_key = consumer_set.new("Not a desktop at all.")
109- >>> ordinary_key.is_integrated_desktop
110- False
111- >>> print ordinary_key.integrated_desktop_type
112- None
113- >>> print ordinary_key.integrated_desktop_name
114- None
115-
116-Request tokens
117-==============
118-
119-When a consumer wants to access protected resources on Launchpad, it
120-must first ask for an OAuthRequestToken, which is then used when the
121-consumer sends the user to the Launchpad authorization page.
122-
123-
124-Creating request tokens
125------------------------
126-
127-The request tokens are created using IOAuthConsumer.newRequestToken().
128-
129- # XXX EdwinGrubbs 2008-10-03 bug=277756
130- # Tests could be simplified with helper methods for creating tokens
131- # in different states.
132- >>> request_token = consumer.newRequestToken()
133- >>> verifyObject(IOAuthRequestToken, request_token)
134- True
135-
136-The token's key and secret have a length of 20 and 80 respectively.
137-
138- >>> len(request_token.key)
139- 20
140- >>> len(request_token.secret)
141- 80
142-
143-Newly created tokens have no context associated with.
144-
145- >>> print request_token.context
146- None
147-
148-Initially, a token does not have a person or permission associated with it as
149-the consumer doesn't know the user's identity on Launchpad.
150-
151- >>> print request_token.person
152- None
153- >>> print request_token.permission
154- None
155- >>> print request_token.date_reviewed
156- None
157-
158-Once the user reviews (approve/decline) the consumer's request, the
159-token is considered used and can only be exchanged for an access token
160-(when the access is granted by the user).
161-
162- >>> salgado = getUtility(IPersonSet).getByName('salgado')
163- >>> request_token.review(salgado, OAuthPermission.WRITE_PUBLIC)
164- >>> from canonical.launchpad.ftests import syncUpdate
165- >>> syncUpdate(request_token)
166-
167- >>> from datetime import datetime, timedelta
168- >>> import pytz
169- >>> print request_token.person.name
170- salgado
171- >>> request_token.permission
172- <DBItem OAuthPermission.WRITE_PUBLIC...
173- >>> request_token.date_reviewed <= datetime.now(pytz.timezone('UTC'))
174- True
175- >>> request_token.is_reviewed
176- True
177-
178-When reviewing a token, we can also change the context associated with
179-it, which means the consumer using that token will only have access
180-to things linked to that context (Product, ProjectGroup, Distribution,
181-DistroSourcePackage).
182-
183- >>> from lp.registry.interfaces.distribution import IDistributionSet
184- >>> from lp.registry.interfaces.product import IProductSet
185- >>> from lp.registry.interfaces.projectgroup import IProjectGroupSet
186-
187- >>> firefox = getUtility(IProductSet)['firefox']
188- >>> request_token2 = consumer.newRequestToken()
189- >>> request_token2.review(
190- ... salgado, OAuthPermission.WRITE_PRIVATE, context=firefox)
191- >>> print request_token2.context.title
192- Mozilla Firefox
193-
194- >>> mozilla = getUtility(IProjectGroupSet)['mozilla']
195- >>> request_token2 = consumer.newRequestToken()
196- >>> request_token2.review(
197- ... salgado, OAuthPermission.WRITE_PRIVATE, context=mozilla)
198- >>> print request_token2.context.title
199- The Mozilla Project
200-
201- >>> ubuntu = getUtility(IDistributionSet)['ubuntu']
202- >>> evolution = ubuntu.getSourcePackage('evolution')
203- >>> request_token2 = consumer.newRequestToken()
204- >>> request_token2.review(
205- ... salgado, OAuthPermission.WRITE_PRIVATE, context=evolution)
206-
207- >>> from canonical.encoding import ascii_smash
208- >>> print ascii_smash(request_token2.context.title)
209- evolution package in Ubuntu
210-
211-
212-Retrieving request tokens
213--------------------------
214-
215-Any consumer can retrieve its request tokens as long as it knows their
216-keys.
217-
218- >>> consumer.getRequestToken(request_token.key) == request_token
219- True
220-
221-If there is no token with the given key, or the existing token is
222-associated with another consumer, getRequestToken() will return None.
223-
224- >>> print consumer.getRequestToken('zzzzzzzz')
225- None
226- >>> consumer2 = consumer_set.new(key='foobar')
227- >>> print consumer2.getRequestToken(request_token.key)
228- None
229-
230-We also have OAuthRequestTokenSet.getByKey(), which allows us to get a
231-request token with the given key regardless of the consumer associated
232-with it.
233-
234- >>> from canonical.launchpad.interfaces.oauth import IOAuthRequestTokenSet
235- >>> token_set = getUtility(IOAuthRequestTokenSet)
236- >>> token_set.getByKey(request_token.key) == request_token
237- True
238-
239- >>> request_token2 = consumer2.newRequestToken()
240- >>> token_set.getByKey(request_token2.key) == request_token2
241- True
242-
243- >>> print token_set.getByKey('zzzzzzzzz')
244- None
245-
246-
247-Exchanging request tokens for access tokens
248--------------------------------------------
249-
250-Once a request token has been reviewed it may be exchanged for an access
251-token. That may happen only if the user actually granted some sort of
252-permission to the consumer when reviewing the request.
253-
254-The access token's permission will be the same as the request token's
255-one, but it is an item of AccessLevel rather than OAuthPermission
256-because the former doesn't have an UNAUTHORIZED item (which doesn't
257-make sense in access tokens).
258-
259- >>> request_token.is_reviewed
260- True
261- >>> request_token.permission
262- <DBItem OAuthPermission.WRITE_PUBLIC...
263- >>> access_token = request_token.createAccessToken()
264- >>> verifyObject(IOAuthAccessToken, access_token)
265- True
266- >>> access_token.permission
267- <DBItem AccessLevel.WRITE_PUBLIC...
268-
269-After the access token is generated, the request token is deleted.
270-
271- >>> print consumer.getRequestToken(request_token.key)
272- None
273-
274-By default, access tokens don't expire.
275-
276- >>> print access_token.date_expires
277- None
278-
279-Access tokens will also inherit the context from the request token.
280-
281- >>> request_token2 = consumer.newRequestToken()
282- >>> request_token2.review(
283- ... salgado, OAuthPermission.WRITE_PRIVATE, context=firefox)
284- >>> access_token2 = request_token2.createAccessToken()
285- >>> print access_token2.context.title
286- Mozilla Firefox
287-
288-If the request token hasn't been reviewed yet, it can't be used to
289-create an access token.
290-
291- >>> request_token = consumer.newRequestToken()
292- >>> request_token.is_reviewed
293- False
294- >>> access_token = request_token.createAccessToken()
295- Traceback (most recent call last):
296- ...
297- AssertionError: ...
298-
299-The same holds true for request tokens that have UNAUTHORIZED as their
300-permission.
301-
302- >>> request_token.review(salgado, OAuthPermission.UNAUTHORIZED)
303- >>> request_token.is_reviewed
304- True
305- >>> access_token = request_token.createAccessToken()
306- Traceback (most recent call last):
307- ...
308- AssertionError: ...
309-
310-
311-Access tokens
312-=============
313-
314-As shown above, access tokens can be created from any reviewed (and
315-authorized) request tokens. These tokens are then stored by the consumer
316-and included in all further requests made on behalf of the same user, so
317-we need a way to retrieve an access token from any consumer.
318-
319- >>> consumer.getAccessToken(access_token.key) == access_token
320- True
321-
322-An access token can only be changed by the person associated with it.
323-
324- >>> access_token.permission = OAuthPermission.WRITE_PUBLIC
325- Traceback (most recent call last):
326- ...
327- Unauthorized:...
328- >>> login_person(access_token.person)
329- >>> access_token.permission = AccessLevel.WRITE_PUBLIC
330-
331-From any given person it's possible to retrieve his non-expired access
332-tokens.
333-
334- >>> access_token.person.oauth_access_tokens.count()
335- 4
336- >>> access_token.date_expires = (
337- ... datetime.now(pytz.timezone('UTC')) - timedelta(hours=1))
338- >>> syncUpdate(access_token)
339- >>> access_token.person.oauth_access_tokens.count()
340- 3
341-
342-It's also possible to retrieve the user's non-expired request tokens.
343-
344- >>> unclaimed_request_token = consumer.newRequestToken()
345- >>> unclaimed_request_token.review(salgado, OAuthPermission.WRITE_PUBLIC)
346- >>> salgado.oauth_request_tokens.count()
347- 5
348- >>> salgado.oauth_request_tokens[0].date_expires = (
349- ... datetime.now(pytz.timezone('UTC')) - timedelta(hours=1))
350- >>> syncUpdate(unclaimed_request_token)
351- >>> salgado.oauth_request_tokens.count()
352- 4
353-
354-A user has edit permission over his own access tokens, he can expire them.
355-
356- >>> api_user = factory.makePerson()
357- >>> login_person(api_user)
358- >>> api_request_token = consumer.newRequestToken()
359- >>> api_request_token.review(api_user, OAuthPermission.WRITE_PUBLIC)
360- >>> api_access_token = api_request_token.createAccessToken()
361- >>> api_access_token.date_expires = (
362- ... datetime.now(pytz.timezone('UTC')) - timedelta(hours=1))
363-
364-
365-Nonces and timestamps
366-=====================
367+= OAuth =
368+
369+Most of the OAuth doctests have been converted into unit tests and
370+moved to test_oauth_tokens.py
371+
372+== Nonces and timestamps ==
373
374 A nonce is a random string, generated by the client for each request.
375
376@@ -469,57 +144,3 @@
377 Traceback (most recent call last):
378 ...
379 TimestampOrderingError: ...
380-
381-
382-Helper methods
383-==============
384-
385-The oauth_access_token_for() helper function makes it easy to get an
386-access token for any user, consumer key, permission, and context.
387-
388-If the user already has an access token that does what you need,
389-oauth_access_token_for() returns the existing token.
390-
391- >>> from lp.testing import oauth_access_token_for
392- >>> existing_token = salgado.oauth_access_tokens[0]
393- >>> token = oauth_access_token_for(
394- ... existing_token.consumer.key, existing_token.person,
395- ... existing_token.permission, existing_token.context)
396-
397- >>> from zope.proxy import sameProxiedObjects
398- >>> sameProxiedObjects(token, existing_token)
399- True
400-
401-If the user does not already have an access token that matches your
402-requirements, oauth_access_token_for() creates a request token and
403-automatically authorizes it. Here, we create a brand new token for a
404-never-before-seen consumer.
405-
406- >>> new_consumer = 'new consumer key to test oauth_access_token_for'
407- >>> token = oauth_access_token_for(
408- ... new_consumer, salgado, 'WRITE_PRIVATE', firefox)
409-
410- >>> print token.consumer.key
411- new consumer key to test oauth_access_token_for
412-
413- >>> print token.person.name
414- salgado
415-
416- >>> token.permission
417- <DBItem AccessLevel.WRITE_PRIVATE...>
418-
419- >>> print token.context.name
420- firefox
421-
422- >>> print token.date_expires
423- None
424-
425-You can use the token identifying one of Launchpad's OAuth permission
426-levels instead of the constant itself, but if you specify a
427-nonexistent permission you'll get an error.
428-
429- >>> oauth_access_token_for(
430- ... new_consumer, salgado, 'NO_SUCH_PERMISSION', firefox)
431- Traceback (most recent call last):
432- ...
433- KeyError: 'NO_SUCH_PERMISSION'
434
435=== modified file 'lib/canonical/launchpad/tests/test_oauth_tokens.py'
436--- lib/canonical/launchpad/tests/test_oauth_tokens.py 2010-10-19 18:51:49 +0000
437+++ lib/canonical/launchpad/tests/test_oauth_tokens.py 2010-10-19 18:51:50 +0000
438@@ -1,35 +1,234 @@
439 # Copyright 2010 Canonical Ltd. This software is licensed under the
440 # GNU Affero General Public License version 3 (see the file LICENSE).
441
442+"""OAuth is a mechanism for allowing a user's desktop or a third-party
443+website to access Launchpad on a user's behalf. These applications
444+are identified by a unique key and are stored as OAuthConsumers. The
445+OAuth specification is defined in <http://oauth.net/core/1.0/>.
446+"""
447+
448 from datetime import (
449 datetime,
450- timedelta
451+ timedelta,
452 )
453+
454 import pytz
455+from zope.component import getUtility
456+from zope.proxy import sameProxiedObjects
457+from zope.security.interfaces import Unauthorized
458
459-from canonical.launchpad.webapp.interfaces import OAuthPermission
460+from canonical.launchpad.ftests import (
461+ login_person,
462+ logout,
463+ )
464+from canonical.launchpad.interfaces.oauth import (
465+ IOAuthAccessToken,
466+ IOAuthConsumer,
467+ IOAuthConsumerSet,
468+ IOAuthRequestToken,
469+ IOAuthRequestTokenSet,
470+ )
471+from canonical.launchpad.webapp.interfaces import (
472+ AccessLevel,
473+ OAuthPermission,
474+ )
475+from canonical.launchpad.webapp.testing import verifyObject
476 from canonical.testing.layers import DatabaseFunctionalLayer
477-
478 from lp.testing import (
479+ oauth_access_token_for,
480 TestCaseWithFactory,
481 )
482
483
484-class TestRequestTokens(TestCaseWithFactory):
485+class TestOAuth(TestCaseWithFactory):
486
487 layer = DatabaseFunctionalLayer
488
489 def setUp(self):
490- """Set up a dummy person and OAuth consumer."""
491- super(TestRequestTokens, self).setUp()
492+ """Set up some convenient data objects and timestamps."""
493+ super(TestOAuth, self).setUp()
494
495 self.person = self.factory.makePerson()
496 self.consumer = self.factory.makeOAuthConsumer()
497
498 now = datetime.now(pytz.timezone('UTC'))
499+ self.in_a_while = now + timedelta(hours=1)
500 self.a_long_time_ago = now - timedelta(hours=1000)
501
502- def testExpiredRequestTokenCantBeReviewed(self):
503+
504+class TestConsumerSet(TestOAuth):
505+ """Tests of the utility that manages OAuth consumers."""
506+
507+ def setUp(self):
508+ super(TestConsumerSet, self).setUp()
509+ self.consumers = getUtility(IOAuthConsumerSet)
510+
511+ def test_interface(self):
512+ verifyObject(IOAuthConsumerSet, self.consumers)
513+
514+ def test_new(self):
515+ consumer = self.consumers.new(
516+ self.factory.getUniqueString("oauthconsumerkey"))
517+ verifyObject(IOAuthConsumer, consumer)
518+
519+ def test_new_wont_create_duplicate_consumer(self):
520+ self.assertRaises(
521+ AssertionError, self.consumers.new, key=self.consumer.key)
522+
523+ def test_getByKey(self):
524+ self.assertEqual(
525+ self.consumers.getByKey(self.consumer.key), self.consumer)
526+
527+ def test_getByKey_returns_none_for_nonexistent_consumer(self):
528+ # There is no consumer called "oauthconsumerkey-nonexistent".
529+ nonexistent_key = self.factory.getUniqueString(
530+ "oauthconsumerkey-nonexistent")
531+ self.assertEqual(self.consumers.getByKey(nonexistent_key), None)
532+
533+
534+class TestRequestTokenSet(TestOAuth):
535+ """Test the set of request tokens."""
536+
537+ def setUp(self):
538+ """Set up a reference to the token list."""
539+ super(TestRequestTokenSet, self).setUp()
540+ self.tokens = getUtility(IOAuthRequestTokenSet)
541+
542+ def test_getByKey(self):
543+ token = self.consumer.newRequestToken()
544+ self.assertEquals(token, self.tokens.getByKey(token.key))
545+
546+ def test_getByKey_returns_none_for_unused_key(self):
547+ self.assertEquals(None, self.tokens.getByKey("no-such-token"))
548+
549+
550+class TestRequestTokens(TestOAuth):
551+ """Tests for OAuth request token objects."""
552+
553+ def test_newRequestToken(self):
554+ request_token = self.consumer.newRequestToken()
555+ verifyObject(IOAuthRequestToken, request_token)
556+
557+ def test_key_and_secret_automatically_generated(self):
558+ request_token = self.consumer.newRequestToken()
559+ self.assertEqual(len(request_token.key), 20)
560+ self.assertEqual(len(request_token.secret), 80)
561+
562+ def test_date_created(self):
563+ request_token = self.consumer.newRequestToken()
564+ now = datetime.now(pytz.timezone('UTC'))
565+ self.assertTrue(request_token.date_created <= now)
566+
567+ def test_new_token_is_not_reviewed(self):
568+ request_token = self.consumer.newRequestToken()
569+ self.assertFalse(request_token.is_reviewed)
570+ self.assertEqual(None, request_token.person)
571+ self.assertEqual(None, request_token.date_reviewed)
572+
573+ # An unreviewed token has no associated permission, expiration
574+ # date, or context.
575+ self.assertEqual(None, request_token.permission)
576+ self.assertEqual(None, request_token.date_expires)
577+ self.assertEqual(None, request_token.context)
578+
579+ def test_getRequestToken(self):
580+ token_1 = self.consumer.newRequestToken()
581+ token_2 = self.consumer.getRequestToken(token_1.key)
582+ self.assertEqual(token_1, token_2)
583+
584+ def test_getRequestToken_for_wrong_consumer_returns_none(self):
585+ token_1 = self.consumer.newRequestToken()
586+ consumer_2 = self.factory.makeOAuthConsumer()
587+ self.assertEquals(
588+ None, consumer_2.getRequestToken(token_1.key))
589+
590+ def test_getRequestToken_for_nonexistent_key_returns_none(self):
591+ self.assertEquals(
592+ None, self.consumer.getRequestToken("no-such-token"))
593+
594+ def test_token_review(self):
595+ request_token = self.consumer.newRequestToken()
596+
597+ request_token.review(self.person, OAuthPermission.WRITE_PUBLIC)
598+ now = datetime.now(pytz.timezone('UTC'))
599+
600+ self.assertTrue(request_token.is_reviewed)
601+ self.assertEquals(request_token.person, self.person)
602+ self.assertEquals(request_token.permission,
603+ OAuthPermission.WRITE_PUBLIC)
604+
605+ self.assertTrue(request_token.date_created <= now)
606+
607+ # By default, reviewing a token does not set a context or
608+ # expiration date.
609+ self.assertEquals(request_token.context, None)
610+ self.assertEquals(request_token.date_expires, None)
611+
612+ def test_token_review_as_unauthorized(self):
613+ request_token = self.consumer.newRequestToken()
614+ request_token.review(self.person, OAuthPermission.UNAUTHORIZED)
615+
616+ # This token has been reviewed, but it may not be used for any
617+ # purpose.
618+ self.assertTrue(request_token.is_reviewed)
619+ self.assertEquals(request_token.permission,
620+ OAuthPermission.UNAUTHORIZED)
621+
622+ def test_review_with_expiration_date(self):
623+ # A request token may be associated with an expiration date
624+ # upon review.
625+ request_token = self.consumer.newRequestToken()
626+ request_token.review(
627+ self.person, OAuthPermission.WRITE_PUBLIC,
628+ date_expires=self.in_a_while)
629+ self.assertEquals(request_token.date_expires, self.in_a_while)
630+
631+ def test_review_with_expiration_date_in_the_past(self):
632+ # The expiration date, like the permission and context, is
633+ # associated with the eventual access token. It has nothing to
634+ # do with how long the *request* token will remain
635+ # valid.
636+ #
637+ # Setting a request token's date_expires to a date in the past
638+ # is not a good idea, but it won't expire the request token.
639+ request_token = self.consumer.newRequestToken()
640+ request_token.review(
641+ self.person, OAuthPermission.WRITE_PUBLIC,
642+ date_expires=self.a_long_time_ago)
643+ self.assertEquals(request_token.date_expires, self.a_long_time_ago)
644+ self.assertFalse(request_token.is_expired)
645+
646+ def _reviewed_token_for_context(self, context_factory):
647+ """Create and review a request token with a given context."""
648+ token = self.consumer.newRequestToken()
649+ name = self.factory.getUniqueString('context')
650+ context = context_factory(name)
651+ token.review(
652+ self.person, OAuthPermission.WRITE_PRIVATE, context=context)
653+ return token, name
654+
655+ def test_review_with_product_context(self):
656+ # When reviewing a request token, the context may be set to a
657+ # product.
658+ token, name = self._reviewed_token_for_context(
659+ self.factory.makeProduct)
660+ self.assertEquals(token.context.name, name)
661+
662+ def test_review_with_project_context(self):
663+ # When reviewing a request token, the context may be set to a
664+ # project.
665+ token, name = self._reviewed_token_for_context(
666+ self.factory.makeProject)
667+ self.assertEquals(token.context.name, name)
668+
669+ def test_review_with_distrosourcepackage_context(self):
670+ # When reviewing a request token, the context may be set to a
671+ # distribution source package.
672+ token, name = self._reviewed_token_for_context(
673+ self.factory.makeDistributionSourcePackage)
674+ self.assertEquals(token.context.name, name)
675+
676+ def test_expired_request_token_cant_be_reviewed(self):
677 """An expired request token can't be reviewed."""
678 token = self.factory.makeOAuthRequestToken(
679 date_created=self.a_long_time_ago)
680@@ -37,7 +236,97 @@
681 AssertionError, token.review, self.person,
682 OAuthPermission.WRITE_PUBLIC)
683
684- def testExpiredRequestTokenCantBeExchanged(self):
685+ def test_get_request_tokens_for_person(self):
686+ """It's possible to get a person's request tokens."""
687+ person = self.factory.makePerson()
688+ self.assertEquals(person.oauth_request_tokens.count(), 0)
689+ for i in range(0,3):
690+ self.factory.makeOAuthRequestToken(reviewed_by=person)
691+ self.assertEquals(person.oauth_request_tokens.count(), 3)
692+
693+ def test_expired_request_token_disappears_from_list(self):
694+ person = self.factory.makePerson()
695+ self.assertEquals(person.oauth_request_tokens.count(), 0)
696+ request_token = self.factory.makeOAuthRequestToken(reviewed_by=person)
697+ self.assertEquals(person.oauth_request_tokens.count(), 1)
698+
699+ login_person(person)
700+ request_token.date_expires = self.a_long_time_ago
701+ logout()
702+
703+ self.assertEquals(person.oauth_request_tokens.count(), 0)
704+
705+
706+class TestAccessTokens(TestOAuth):
707+ """Tests for OAuth access tokens."""
708+
709+ def _exchange_request_token_for_access_token(self):
710+ # Use this method instead of factory.makeOAuthAccessToken() to
711+ # a) to show how a request token is exchanged for an access
712+ # token, b) acquire a reference to the request token that was
713+ # used to create the access token.
714+ request_token = self.consumer.newRequestToken()
715+ request_token.review(self.person, OAuthPermission.WRITE_PRIVATE)
716+ access_token = request_token.createAccessToken()
717+ return request_token, access_token
718+
719+ def test_exchange_request_token_for_access_token(self):
720+ # Make sure the basic exchange of request token for access
721+ # token works.
722+ request_token, access_token = (
723+ self._exchange_request_token_for_access_token())
724+ verifyObject(IOAuthAccessToken, access_token)
725+
726+ def test_access_token_inherits_data_fields_from_request_token(self):
727+ request_token, access_token = (
728+ self._exchange_request_token_for_access_token())
729+
730+ self.assertEquals(request_token.consumer, access_token.consumer)
731+
732+ # An access token inherits its permission from the request
733+ # token that created it. But an access token's .permission is
734+ # an AccessLevel object, not an OAuthPermission. The only real
735+ # difference is that there's no AccessLevel corresponding to
736+ # OAuthPermission.UNAUTHORIZED.
737+ self.assertEquals(
738+ access_token.permission, AccessLevel.WRITE_PRIVATE)
739+
740+ self.assertEquals(None, access_token.context)
741+ self.assertEquals(None, access_token.date_expires)
742+
743+ def test_access_token_field_inheritance(self):
744+ # Make sure that specific fields like context and expiration
745+ # date are passed down from request token to access token.
746+ context = self.factory.makeProduct()
747+ request_token = self.consumer.newRequestToken()
748+ request_token.review(
749+ self.person, OAuthPermission.WRITE_PRIVATE,
750+ context=context, date_expires=self.in_a_while)
751+
752+ access_token = request_token.createAccessToken()
753+ self.assertEquals(request_token.context, access_token.context)
754+ self.assertEquals(
755+ request_token.date_expires, access_token.date_expires)
756+
757+ def test_request_token_disappears_when_exchanged(self):
758+ request_token, access_token = (
759+ self._exchange_request_token_for_access_token())
760+ self.assertEquals(
761+ None, self.consumer.getRequestToken(request_token.key))
762+
763+ def test_cant_exchange_unreviewed_request_token(self):
764+ # An unreviewed request token cannot be exchanged for an access token.
765+ token = self.consumer.newRequestToken()
766+ self.assertRaises(AssertionError, token.createAccessToken)
767+
768+ def test_cant_exchange_unauthorized_request_token(self):
769+ # A request token associated with the UNAUTHORIZED
770+ # OAuthPermission cannot be exchanged for an access token.
771+ token = self.consumer.newRequestToken()
772+ token.review(self.person, OAuthPermission.UNAUTHORIZED)
773+ self.assertRaises(AssertionError, token.createAccessToken)
774+
775+ def test_expired_request_token_cant_be_exchanged(self):
776 """An expired request token can't be exchanged for an access token.
777
778 This can only happen if the token was reviewed before it expired.
779@@ -45,3 +334,80 @@
780 token = self.factory.makeOAuthRequestToken(
781 date_created=self.a_long_time_ago, reviewed_by=self.person)
782 self.assertRaises(AssertionError, token.createAccessToken)
783+
784+ def test_write_permission(self):
785+ """An access token can only be modified by its creator."""
786+ access_token = self.factory.makeOAuthAccessToken()
787+ def try_to_set():
788+ access_token.permission = AccessLevel.WRITE_PUBLIC
789+ self.assertRaises(Unauthorized, try_to_set)
790+
791+ login_person(access_token.person)
792+ try_to_set()
793+ logout()
794+
795+ def test_get_access_tokens_for_person(self):
796+ """It's possible to get a person's access tokens."""
797+ person = self.factory.makePerson()
798+ self.assertEquals(person.oauth_access_tokens.count(), 0)
799+ for i in range(0,3):
800+ self.factory.makeOAuthAccessToken(self.consumer, person)
801+ self.assertEquals(person.oauth_access_tokens.count(), 3)
802+
803+ def test_expired_access_token_disappears_from_list(self):
804+ person = self.factory.makePerson()
805+ self.assertEquals(person.oauth_access_tokens.count(), 0)
806+ access_token = self.factory.makeOAuthAccessToken(
807+ self.consumer, person)
808+ self.assertEquals(person.oauth_access_tokens.count(), 1)
809+
810+ login_person(access_token.person)
811+ access_token.date_expires = self.a_long_time_ago
812+ logout()
813+ self.assertEquals(person.oauth_access_tokens.count(), 0)
814+
815+
816+class TestHelperFunctions(TestOAuth):
817+
818+ def setUp(self):
819+ super(TestHelperFunctions, self).setUp()
820+ self.context = self.factory.makeProduct()
821+
822+ def test_oauth_access_token_for_creates_nonexistent_token(self):
823+ # If there's no token for user/consumer key/permission/context,
824+ # one is created.
825+ person = self.factory.makePerson()
826+ self.assertEquals(person.oauth_access_tokens.count(), 0)
827+ access_token = oauth_access_token_for(
828+ self.consumer.key, person, OAuthPermission.WRITE_PUBLIC,
829+ self.context)
830+ self.assertEquals(person.oauth_access_tokens.count(), 1)
831+
832+ def test_oauth_access_token_for_retrieves_existing_token(self):
833+ # If there's already a token for a
834+ # user/consumer key/permission/context, it's retrieved.
835+ person = self.factory.makePerson()
836+ self.assertEquals(person.oauth_access_tokens.count(), 0)
837+ access_token = oauth_access_token_for(
838+ self.consumer.key, person, OAuthPermission.WRITE_PUBLIC,
839+ self.context)
840+ self.assertEquals(person.oauth_access_tokens.count(), 1)
841+
842+ access_token_2 = oauth_access_token_for(
843+ access_token.consumer.key, access_token.person,
844+ access_token.permission, access_token.context)
845+ self.assertEquals(person.oauth_access_tokens.count(), 1)
846+ self.assertTrue(sameProxiedObjects(access_token, access_token_2))
847+
848+ def test_oauth_access_token_string_permission(self):
849+ """You can pass in a string instead of an OAuthPermission."""
850+ access_token = oauth_access_token_for(
851+ self.consumer.key, self.person, 'WRITE_PUBLIC')
852+ self.assertEqual(access_token.permission, AccessLevel.WRITE_PUBLIC)
853+
854+ def test_oauth_access_token_string_with_nonexistent_permission(self):
855+ # NO_SUCH_PERMISSION doesn't correspond to any OAuthPermission
856+ # object.
857+ self.assertRaises(
858+ KeyError, oauth_access_token_for, self.consumer.key,
859+ self.person, 'NO_SUCH_PERMISSION')
860
861=== modified file 'lib/lp/testing/factory.py'
862--- lib/lp/testing/factory.py 2010-10-19 18:51:49 +0000
863+++ lib/lp/testing/factory.py 2010-10-19 18:51:50 +0000
864@@ -3186,9 +3186,9 @@
865 secret = ''
866 return getUtility(IOAuthConsumerSet).new(key, secret)
867
868- def makeOAuthRequestToken(
869- self, consumer=None, date_created=None, reviewed_by=None,
870- access_level=OAuthPermission.READ_PUBLIC):
871+ def makeOAuthRequestToken(self, consumer=None, date_created=None,
872+ reviewed_by=None,
873+ access_level=OAuthPermission.READ_PUBLIC):
874 """Create a (possibly reviewed) OAuth request token."""
875 if consumer is None:
876 consumer = self.makeOAuthConsumer()
877@@ -3205,6 +3205,15 @@
878 unwrapped_token.date_created = date_created
879 return token
880
881+ def makeOAuthAccessToken(self, consumer=None, owner=None,
882+ access_level=OAuthPermission.READ_PUBLIC):
883+ """Create an OAuth access token."""
884+ if owner is None:
885+ owner = self.makePerson()
886+ request_token = self.makeOAuthRequestToken(
887+ consumer, reviewed_by=owner, access_level=access_level)
888+ return request_token.createAccessToken()
889+
890
891 # Some factory methods return simple Python types. We don't add
892 # security wrappers for them, as well as for objects created by