Merge lp:~sinzui/launchpad/private-traversal-4 into lp:launchpad
- private-traversal-4
- Merge into devel
Status: | Merged | ||||||||
---|---|---|---|---|---|---|---|---|---|
Approved by: | Curtis Hovey | ||||||||
Approved revision: | no longer in the source branch. | ||||||||
Merged at revision: | 14513 | ||||||||
Proposed branch: | lp:~sinzui/launchpad/private-traversal-4 | ||||||||
Merge into: | lp:launchpad | ||||||||
Diff against target: |
978 lines (+419/-174) 16 files modified
lib/canonical/launchpad/interfaces/_schema_circular_imports.py (+6/-5) lib/canonical/launchpad/security.py (+38/-27) lib/lp/app/browser/launchpad.py (+2/-2) lib/lp/app/browser/tales.py (+8/-0) lib/lp/app/tests/test_tales.py (+16/-9) lib/lp/bugs/browser/tests/test_bugsubscription_views.py (+1/-1) lib/lp/code/browser/branch.py (+9/-1) lib/lp/code/browser/branchsubscription.py (+14/-2) lib/lp/code/browser/tests/test_branch.py (+100/-0) lib/lp/code/browser/tests/test_branchlisting.py (+4/-3) lib/lp/code/stories/branches/xx-subscribing-branches.txt (+14/-20) lib/lp/registry/configure.zcml (+6/-4) lib/lp/registry/doc/private-team-visibility.txt (+61/-0) lib/lp/registry/interfaces/person.py (+99/-99) lib/lp/registry/interfaces/role.py (+2/-1) lib/lp/soyuz/tests/test_archive_subscriptions.py (+39/-0) |
||||||||
To merge this branch: | bzr merge lp:~sinzui/launchpad/private-traversal-4 | ||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ian Booth (community) | code | Approve | |
Steve Kowalik (community) | code | Approve | |
Review via email: mp+85561@code.launchpad.net |
Commit message
[r=stevenk,
Description of the change
Allows subscribers to traverse to archives owned by private teams.
Launchpad bug: https:/
Pre-
The branch that fixed the security checker rules for private teams with
subscribable artefacts was rolled back because QA found traversal errors.
The test suite should not have passed. This issue was reported 12 months
ago, but the fix that was landed did not include a test for this case.
Module lp.registry.
return traverse_
Module lp.soyuz.
archive = person.
Unauthorized: (<Person at ... canonical-isd (Canonical ISD)>,
-------
RULES
* Write a test that reproduces the error seen on qastaging:
* Update the interfaces or permissions as needed to make the
test pass.
* Consider previewing the branch on a server with production
data to ensure subscribers can access private team archives.
QA
* As an Lp engineer, visit
https:/
* Verify the page renders.
* Verify you can expand the packages to learn the details.
LINT
lib/
lib/
lib/
TEST
./bin/test -vvc lp.soyuz.
IMPLEMENTATION
I tried the test_traverse helper, but discovered it fails because it does
not work with LaunchpadTestRe
.features which is required Lp's base template).
I Wrote a browser test that uses traversal and supports proper view rendering.
I then moved getPPAByName() to IPersonLimitedView.
lib/
lib/
lib/
Curtis Hovey (sinzui) wrote : | # |
Steve Kowalik (stevenk) wrote : | # |
Broadly, this looks good to me. The only comment I have is, have you thought about using with person_logged_in()?
Curtis Hovey (sinzui) wrote : | # |
I can certainly switch to person_logged_in()
Ian Booth (wallyworld) wrote : | # |
Looks good. Thanks for fixing.
Preview Diff
1 | === modified file 'lib/canonical/launchpad/interfaces/_schema_circular_imports.py' |
2 | --- lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2011-12-13 13:53:12 +0000 |
3 | +++ lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2011-12-14 03:28:31 +0000 |
4 | @@ -135,7 +135,8 @@ |
5 | ) |
6 | from lp.registry.interfaces.person import ( |
7 | IPerson, |
8 | - IPersonPublic, |
9 | + IPersonLimitedView, |
10 | + IPersonViewRestricted, |
11 | ITeam, |
12 | ) |
13 | from lp.registry.interfaces.pillar import ( |
14 | @@ -310,10 +311,10 @@ |
15 | |
16 | IPreviewDiff['branch_merge_proposal'].schema = IBranchMergeProposal |
17 | |
18 | -patch_reference_property(IPersonPublic, 'archive', IArchive) |
19 | -patch_collection_property(IPersonPublic, 'ppas', IArchive) |
20 | -patch_entry_return_type(IPersonPublic, 'getPPAByName', IArchive) |
21 | -patch_entry_return_type(IPersonPublic, 'createPPA', IArchive) |
22 | +patch_reference_property(IPersonViewRestricted, 'archive', IArchive) |
23 | +patch_collection_property(IPersonViewRestricted, 'ppas', IArchive) |
24 | +patch_entry_return_type(IPersonLimitedView, 'getPPAByName', IArchive) |
25 | +patch_entry_return_type(IPersonViewRestricted, 'createPPA', IArchive) |
26 | |
27 | IHasBuildRecords['getBuildRecords'].queryTaggedValue( |
28 | LAZR_WEBSERVICE_EXPORTED)[ |
29 | |
30 | === modified file 'lib/canonical/launchpad/security.py' |
31 | --- lib/canonical/launchpad/security.py 2011-12-13 13:53:12 +0000 |
32 | +++ lib/canonical/launchpad/security.py 2011-12-14 03:28:31 +0000 |
33 | @@ -63,6 +63,7 @@ |
34 | IBranch, |
35 | user_has_special_branch_access, |
36 | ) |
37 | +from lp.code.interfaces.branchcollection import IBranchCollection |
38 | from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal |
39 | from lp.code.interfaces.branchmergequeue import IBranchMergeQueue |
40 | from lp.code.interfaces.codeimport import ICodeImport |
41 | @@ -800,6 +801,35 @@ |
42 | if (invitee.is_team and |
43 | invitee in user.person.getAdministratedTeams()): |
44 | return True |
45 | + return False |
46 | + |
47 | + |
48 | +class PublicOrPrivateTeamsExistence(AuthorizationBase): |
49 | + """Restrict knowing about private teams' existence. |
50 | + |
51 | + Knowing the existence of a private team allow traversing to its URL and |
52 | + displaying basic information like name, displayname. |
53 | + """ |
54 | + permission = 'launchpad.LimitedView' |
55 | + usedfor = IPersonLimitedView |
56 | + |
57 | + def checkUnauthenticated(self): |
58 | + """Unauthenticated users can only view public teams.""" |
59 | + if self.obj.visibility == PersonVisibility.PUBLIC: |
60 | + return True |
61 | + return False |
62 | + |
63 | + def checkAuthenticated(self, user): |
64 | + """By default, we simply perform a View permission check. |
65 | + |
66 | + We also grant limited viewability to users who can see PPAs and |
67 | + branches owned by the team. In other scenarios, the context in which |
68 | + the permission is required is responsible for pre-caching the |
69 | + launchpad.LimitedView permission on each team which requires it. |
70 | + """ |
71 | + if self.forwardCheckAuthenticated( |
72 | + user, self.obj, 'launchpad.View'): |
73 | + return True |
74 | |
75 | if (self.obj.is_team |
76 | and self.obj.visibility == PersonVisibility.PRIVATE): |
77 | @@ -813,33 +843,14 @@ |
78 | ppa.id for ppa in self.obj.ppas if ppa.private) |
79 | if len(subscriber_archive_ids.intersection(team_ppa_ids)) > 0: |
80 | return True |
81 | - return False |
82 | - |
83 | - |
84 | -class PublicOrPrivateTeamsExistence(AuthorizationBase): |
85 | - """Restrict knowing about private teams' existence. |
86 | - |
87 | - Knowing the existence of a private team allow traversing to its URL and |
88 | - displaying basic information like name, displayname. |
89 | - """ |
90 | - permission = 'launchpad.LimitedView' |
91 | - usedfor = IPersonLimitedView |
92 | - |
93 | - def checkUnauthenticated(self): |
94 | - """Unauthenticated users can only view public teams.""" |
95 | - if self.obj.visibility == PersonVisibility.PUBLIC: |
96 | - return True |
97 | - return False |
98 | - |
99 | - def checkAuthenticated(self, user): |
100 | - """By default, we simply perform a View permission check. |
101 | - |
102 | - The context in which the permission is required is |
103 | - responsible for pre-caching the launchpad.LimitedView permission on |
104 | - each team which requires it. |
105 | - """ |
106 | - return self.forwardCheckAuthenticated( |
107 | - user, self.obj, 'launchpad.View') |
108 | + |
109 | + # Grant visibility to people with subscriptions to branches owned |
110 | + # by the private team. |
111 | + owned_branches = getUtility(IBranchCollection).ownedBy(self.obj) |
112 | + if owned_branches.visibleByUser(user.person).count() > 0: |
113 | + return True |
114 | + |
115 | + return False |
116 | |
117 | |
118 | class EditPollByTeamOwnerOrTeamAdminsOrAdmins( |
119 | |
120 | === modified file 'lib/lp/app/browser/launchpad.py' |
121 | --- lib/lp/app/browser/launchpad.py 2011-12-13 13:53:12 +0000 |
122 | +++ lib/lp/app/browser/launchpad.py 2011-12-14 03:28:31 +0000 |
123 | @@ -724,8 +724,8 @@ |
124 | # Check to see if this is a team, and if so, whether the |
125 | # logged in user is allowed to view the team, by virtue of |
126 | # team membership or Launchpad administration. |
127 | - if (person.is_team |
128 | - and not check_permission('launchpad.View', person)): |
129 | + if (person.is_team and |
130 | + not check_permission('launchpad.LimitedView', person)): |
131 | raise NotFound(self.context, name) |
132 | # Only admins are permitted to see suspended users. |
133 | if person.account_status == AccountStatus.SUSPENDED: |
134 | |
135 | === modified file 'lib/lp/app/browser/tales.py' |
136 | --- lib/lp/app/browser/tales.py 2011-12-13 19:38:29 +0000 |
137 | +++ lib/lp/app/browser/tales.py 2011-12-14 03:28:31 +0000 |
138 | @@ -1266,6 +1266,14 @@ |
139 | self.hidden) |
140 | return super(TeamFormatterAPI, self).link(view_name, rootsite) |
141 | |
142 | + def icon(self, view_name): |
143 | + team = self._context |
144 | + if not check_permission('launchpad.LimitedView', team): |
145 | + css_class = ObjectImageDisplayAPI(team).sprite_css() |
146 | + return '<span class="' + css_class + '"></span>' |
147 | + else: |
148 | + return super(TeamFormatterAPI, self).icon(view_name) |
149 | + |
150 | def displayname(self, view_name, rootsite=None): |
151 | """See `PersonFormatterAPI`.""" |
152 | person = self._context |
153 | |
154 | === modified file 'lib/lp/app/tests/test_tales.py' |
155 | --- lib/lp/app/tests/test_tales.py 2011-12-13 13:53:12 +0000 |
156 | +++ lib/lp/app/tests/test_tales.py 2011-12-14 03:28:31 +0000 |
157 | @@ -149,18 +149,20 @@ |
158 | self.assertEqual(expected, result) |
159 | |
160 | |
161 | -class TestTalesFormatterAPI(TestCaseWithFactory): |
162 | - """ Test permissions required to access TalesFormatterAPI methods. |
163 | +class TestTeamFormatterAPI(TestCaseWithFactory): |
164 | + """ Test permissions required to access TeamFormatterAPI methods. |
165 | |
166 | A user must have launchpad.LimitedView permission to use |
167 | - TestTalesFormatterAPI with private teams. |
168 | + TeamFormatterAPI with private teams. |
169 | """ |
170 | - layer = DatabaseFunctionalLayer |
171 | + layer = LaunchpadFunctionalLayer |
172 | |
173 | def setUp(self): |
174 | - super(TestTalesFormatterAPI, self).setUp() |
175 | + super(TestTeamFormatterAPI, self).setUp() |
176 | + icon = self.factory.makeLibraryFileAlias( |
177 | + filename='smurf.png', content_type='image/png') |
178 | self.team = self.factory.makeTeam( |
179 | - name='team', displayname='a team', |
180 | + name='team', displayname='a team', icon=icon, |
181 | visibility=PersonVisibility.PRIVATE) |
182 | |
183 | def _make_formatter(self, cache_permission=False): |
184 | @@ -175,10 +177,11 @@ |
185 | request, 'launchpad.LimitedView', [self.team]) |
186 | return formatter, request, any_person |
187 | |
188 | - def _tales_value(self, attr, request): |
189 | + def _tales_value(self, attr, request, path='fmt'): |
190 | # Evaluate the given formatted attribute value on team. |
191 | - return test_tales( |
192 | - "team/fmt:%s" % attr, team=self.team, request=request) |
193 | + result = test_tales( |
194 | + "team/%s:%s" % (path, attr), team=self.team, request=request) |
195 | + return result |
196 | |
197 | def _test_can_view_attribute_no_login(self, attr, hidden=None): |
198 | # Test attribute access with no login. |
199 | @@ -228,6 +231,10 @@ |
200 | def test_can_view_url(self): |
201 | self._test_can_view_attribute('url') |
202 | |
203 | + def test_can_view_icon(self): |
204 | + self._test_can_view_attribute( |
205 | + 'icon', '<span class="sprite team"></span>') |
206 | + |
207 | |
208 | class TestObjectFormatterAPI(TestCaseWithFactory): |
209 | """Tests for ObjectFormatterAPI""" |
210 | |
211 | === modified file 'lib/lp/bugs/browser/tests/test_bugsubscription_views.py' |
212 | --- lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-12-13 13:53:12 +0000 |
213 | +++ lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-12-14 03:28:31 +0000 |
214 | @@ -629,7 +629,7 @@ |
215 | direct_subscriber = self.factory.makeTeam( |
216 | name='team', displayname='Team Name', owner=teamowner, |
217 | visibility=PersonVisibility.PRIVATE) |
218 | - with person_logged_in(direct_subscriber.teamowner): |
219 | + with person_logged_in(teamowner): |
220 | bug.subscribe(direct_subscriber, direct_subscriber.teamowner, |
221 | level=BugNotificationLevel.LIFECYCLE) |
222 | |
223 | |
224 | === modified file 'lib/lp/code/browser/branch.py' |
225 | --- lib/lp/code/browser/branch.py 2011-12-13 13:53:12 +0000 |
226 | +++ lib/lp/code/browser/branch.py 2011-12-14 03:28:31 +0000 |
227 | @@ -96,7 +96,10 @@ |
228 | stepthrough, |
229 | stepto, |
230 | ) |
231 | -from canonical.launchpad.webapp.authorization import check_permission |
232 | +from canonical.launchpad.webapp.authorization import ( |
233 | + check_permission, |
234 | + precache_permission_for_objects, |
235 | + ) |
236 | from canonical.launchpad.webapp.interfaces import ICanonicalUrlData |
237 | from canonical.launchpad.webapp.menu import structured |
238 | from lp.app.browser.launchpad import Hierarchy |
239 | @@ -442,6 +445,11 @@ |
240 | def initialize(self): |
241 | self.branch = self.context |
242 | self.notices = [] |
243 | + # Cache permission so private team owner can be rendered. |
244 | + authorised_people = [self.branch.owner] |
245 | + if self.user is not None: |
246 | + precache_permission_for_objects( |
247 | + self.request, "launchpad.LimitedView", authorised_people) |
248 | # Replace our context with a decorated branch, if it is not already |
249 | # decorated. |
250 | if not isinstance(self.context, DecoratedBranch): |
251 | |
252 | === modified file 'lib/lp/code/browser/branchsubscription.py' |
253 | --- lib/lp/code/browser/branchsubscription.py 2011-12-13 13:53:12 +0000 |
254 | +++ lib/lp/code/browser/branchsubscription.py 2011-12-14 03:28:31 +0000 |
255 | @@ -19,7 +19,10 @@ |
256 | canonical_url, |
257 | LaunchpadView, |
258 | ) |
259 | -from canonical.launchpad.webapp.authorization import check_permission |
260 | +from canonical.launchpad.webapp.authorization import ( |
261 | + check_permission, |
262 | + precache_permission_for_objects, |
263 | + ) |
264 | from canonical.launchpad.webapp.interfaces import IPrimaryContext |
265 | from canonical.launchpad.webapp.menu import structured |
266 | from lp.app.browser.launchpadform import ( |
267 | @@ -48,9 +51,18 @@ |
268 | |
269 | def subscriptions(self): |
270 | """Return a decorated list of branch subscriptions.""" |
271 | + |
272 | + # Cache permissions so private subscribers can be rendered. |
273 | + if self.user is not None: |
274 | + subscribers = [ |
275 | + subscription.person |
276 | + for subscription in self.context.subscriptions] |
277 | + precache_permission_for_objects( |
278 | + self.request, "launchpad.LimitedView", subscribers) |
279 | + |
280 | visible_subscriptions = [ |
281 | subscription for subscription in self.context.subscriptions |
282 | - if check_permission('launchpad.View', subscription.person)] |
283 | + if check_permission('launchpad.LimitedView', subscription.person)] |
284 | return sorted( |
285 | visible_subscriptions, |
286 | key=lambda subscription: subscription.person.displayname) |
287 | |
288 | === modified file 'lib/lp/code/browser/tests/test_branch.py' |
289 | --- lib/lp/code/browser/tests/test_branch.py 2011-12-13 13:53:12 +0000 |
290 | +++ lib/lp/code/browser/tests/test_branch.py 2011-12-14 03:28:31 +0000 |
291 | @@ -5,12 +5,14 @@ |
292 | |
293 | __metaclass__ = type |
294 | |
295 | +from BeautifulSoup import BeautifulSoup |
296 | from datetime import ( |
297 | datetime, |
298 | ) |
299 | from textwrap import dedent |
300 | |
301 | import pytz |
302 | +from zope.publisher.interfaces import NotFound |
303 | from zope.security.proxy import removeSecurityProxy |
304 | |
305 | from canonical.config import config |
306 | @@ -26,6 +28,7 @@ |
307 | extract_text, |
308 | find_tag_by_id, |
309 | setupBrowser, |
310 | + setupBrowserForUser |
311 | ) |
312 | from lp.app.interfaces.headings import IRootContext |
313 | from lp.bugs.interfaces.bugtask import ( |
314 | @@ -50,6 +53,7 @@ |
315 | BranchVisibilityRule, |
316 | ) |
317 | from lp.code.interfaces.branchtarget import IBranchTarget |
318 | +from lp.registry.interfaces.person import PersonVisibility |
319 | from lp.testing import ( |
320 | BrowserTestCase, |
321 | login, |
322 | @@ -541,6 +545,102 @@ |
323 | self.assertEqual(linked_bug_urls[1], links[4]['href']) |
324 | |
325 | |
326 | +class TestBranchViewPrivateArtifacts(BrowserTestCase): |
327 | + """ Tests that branches with private team artifacts can be viewed. |
328 | + |
329 | + A Branch may be associated with a private team as follows: |
330 | + - the owner is a private team |
331 | + - a subscriber is a private team |
332 | + |
333 | + A logged in user who is not authorised to see the private team(s) still |
334 | + needs to be able to view the branch. The private team will be rendered in |
335 | + the normal way, displaying the team name and Launchpad URL. |
336 | + """ |
337 | + |
338 | + layer = DatabaseFunctionalLayer |
339 | + |
340 | + def _getBrowser(self, user=None): |
341 | + if user is None: |
342 | + browser = setupBrowser() |
343 | + logout() |
344 | + return browser |
345 | + else: |
346 | + login_person(user) |
347 | + return setupBrowserForUser(user=user) |
348 | + |
349 | + def test_view_branch_with_private_owner(self): |
350 | + # A branch with a private owner is rendered. |
351 | + private_owner = self.factory.makeTeam( |
352 | + displayname="PrivateTeam", visibility=PersonVisibility.PRIVATE) |
353 | + branch = self.factory.makeAnyBranch(owner=private_owner) |
354 | + # Ensure the branch owner is rendered. |
355 | + url = canonical_url(branch, rootsite='code') |
356 | + user = self.factory.makePerson() |
357 | + browser = self._getBrowser(user) |
358 | + browser.open(url) |
359 | + soup = BeautifulSoup(browser.contents) |
360 | + self.assertIsNotNone(soup.find('a', text="PrivateTeam")) |
361 | + |
362 | + def test_view_private_branch_with_private_owner(self): |
363 | + # A private branch with a private owner is rendered. |
364 | + private_owner = self.factory.makeTeam( |
365 | + displayname="PrivateTeam", visibility=PersonVisibility.PRIVATE) |
366 | + branch = self.factory.makeAnyBranch(owner=private_owner) |
367 | + # Ensure the branch owner is rendered. |
368 | + url = canonical_url(branch, rootsite='code') |
369 | + user = self.factory.makePerson() |
370 | + # Subscribe the user so they can see the branch. |
371 | + with person_logged_in(private_owner): |
372 | + self.factory.makeBranchSubscription(branch, user, private_owner) |
373 | + browser = self._getBrowser(user) |
374 | + browser.open(url) |
375 | + soup = BeautifulSoup(browser.contents) |
376 | + self.assertIsNotNone(soup.find('a', text="PrivateTeam")) |
377 | + |
378 | + def test_anonymous_view_branch_with_private_owner(self): |
379 | + # A branch with a private owner is not rendered for anon users. |
380 | + private_owner = self.factory.makeTeam( |
381 | + visibility=PersonVisibility.PRIVATE) |
382 | + branch = self.factory.makeAnyBranch(owner=private_owner) |
383 | + # Viewing the branch results in an error. |
384 | + url = canonical_url(branch, rootsite='code') |
385 | + browser = self._getBrowser() |
386 | + self.assertRaises(NotFound, browser.open, url) |
387 | + |
388 | + def test_view_branch_with_private_subscriber(self): |
389 | + # A branch with a private subscriber is rendered. |
390 | + private_subscriber = self.factory.makeTeam( |
391 | + name="privateteam", visibility=PersonVisibility.PRIVATE) |
392 | + branch = self.factory.makeAnyBranch() |
393 | + with person_logged_in(branch.owner): |
394 | + self.factory.makeBranchSubscription( |
395 | + branch, private_subscriber, branch.owner) |
396 | + # Ensure the branch subscriber is rendered. |
397 | + url = canonical_url(branch, rootsite='code') |
398 | + user = self.factory.makePerson() |
399 | + browser = self._getBrowser(user) |
400 | + browser.open(url) |
401 | + soup = BeautifulSoup(browser.contents) |
402 | + self.assertIsNotNone( |
403 | + soup.find('div', attrs={'id': 'subscriber-privateteam'})) |
404 | + |
405 | + def test_anonymous_view_branch_with_private_subscriber(self): |
406 | + # A branch with a private subscriber is not rendered for anon users. |
407 | + private_subscriber = self.factory.makeTeam( |
408 | + name="privateteam", visibility=PersonVisibility.PRIVATE) |
409 | + branch = self.factory.makeAnyBranch() |
410 | + with person_logged_in(branch.owner): |
411 | + self.factory.makeBranchSubscription( |
412 | + branch, private_subscriber, branch.owner) |
413 | + # Viewing the branch results in an error. |
414 | + url = canonical_url(branch, rootsite='code') |
415 | + browser = self._getBrowser() |
416 | + browser.open(url) |
417 | + soup = BeautifulSoup(browser.contents) |
418 | + self.assertIsNone( |
419 | + soup.find('div', attrs={'id': 'subscriber-privateteam'})) |
420 | + |
421 | + |
422 | class TestBranchAddView(TestCaseWithFactory): |
423 | """Test the BranchAddView view.""" |
424 | |
425 | |
426 | === modified file 'lib/lp/code/browser/tests/test_branchlisting.py' |
427 | --- lib/lp/code/browser/tests/test_branchlisting.py 2011-12-13 13:53:12 +0000 |
428 | +++ lib/lp/code/browser/tests/test_branchlisting.py 2011-12-14 03:28:31 +0000 |
429 | @@ -613,13 +613,14 @@ |
430 | layer = DatabaseFunctionalLayer |
431 | |
432 | def _make_branch_for_private_team(self): |
433 | + owner = self.factory.makePerson() |
434 | private_team = self.factory.makeTeam( |
435 | - name='shh', displayname='Shh', |
436 | + name='shh', displayname='Shh', owner=owner, |
437 | visibility=PersonVisibility.PRIVATE) |
438 | member = self.factory.makePerson( |
439 | email='member@example.com', password='test') |
440 | - with person_logged_in(private_team.teamowner): |
441 | - private_team.addMember(member, private_team.teamowner) |
442 | + with person_logged_in(owner): |
443 | + private_team.addMember(member, owner) |
444 | branch = self.factory.makeProductBranch(owner=private_team) |
445 | return private_team, member, branch |
446 | |
447 | |
448 | === modified file 'lib/lp/code/stories/branches/xx-subscribing-branches.txt' |
449 | --- lib/lp/code/stories/branches/xx-subscribing-branches.txt 2011-12-13 13:53:12 +0000 |
450 | +++ lib/lp/code/stories/branches/xx-subscribing-branches.txt 2011-12-14 03:28:31 +0000 |
451 | @@ -254,15 +254,19 @@ |
452 | Mark Shuttleworth |
453 | |
454 | |
455 | -Private team exclusion |
456 | -====================== |
457 | - |
458 | -If a private team is subscribed to the branch, it is excluded from the list of |
459 | -subscribers if the user is not an admin or a member of the team. |
460 | - |
461 | - >>> from lp.testing import ANONYMOUS, login, logout |
462 | +Private team's in public subscriptions |
463 | +====================================== |
464 | + |
465 | +If a private team is subscribed to a publicthe branch, it is visible |
466 | +to everyone. |
467 | + |
468 | + >>> from lp.testing import login, logout |
469 | + >>> from lp.registry.interfaces.person import PersonVisibility |
470 | + >>> from lp.code.enums import ( |
471 | + ... BranchSubscriptionNotificationLevel, |
472 | + ... CodeReviewNotificationLevel) |
473 | + |
474 | >>> login('admin@canonical.com') |
475 | - >>> from lp.registry.interfaces.person import PersonVisibility |
476 | >>> private_team = factory.makeTeam( |
477 | ... name='shh', displayname='Shh', |
478 | ... visibility=PersonVisibility.PRIVATE) |
479 | @@ -271,26 +275,16 @@ |
480 | >>> ignored = private_team.addMember(member, private_team.teamowner) |
481 | >>> owner = factory.makePerson(name='branch-owner') |
482 | >>> branch = factory.makeAnyBranch(owner=owner) |
483 | - >>> from lp.code.enums import ( |
484 | - ... BranchSubscriptionNotificationLevel, |
485 | - ... CodeReviewNotificationLevel) |
486 | >>> ignored = branch.subscribe( |
487 | ... private_team, BranchSubscriptionNotificationLevel.NOEMAIL, None, |
488 | ... CodeReviewNotificationLevel.NOEMAIL, private_team.teamowner) |
489 | >>> url = canonical_url(branch) |
490 | >>> logout() |
491 | |
492 | -No-priv is not a member of the private team, so the private team is not shown |
493 | -as a subscriber. |
494 | +No-priv is not a member of the private team, but he can see the team's |
495 | +display name in the subscriber list. |
496 | |
497 | >>> browser.open(url) |
498 | >>> print_subscribers(browser.contents) |
499 | Branch-owner |
500 | - |
501 | -A team member looking at the branch will see the private team subscribed. |
502 | - |
503 | - >>> private_browser = setupBrowser(auth='Basic shh@example.com:test') |
504 | - >>> private_browser.open(url) |
505 | - >>> print_subscribers(private_browser.contents) |
506 | - Branch-owner |
507 | Shh |
508 | |
509 | === modified file 'lib/lp/registry/configure.zcml' |
510 | --- lib/lp/registry/configure.zcml 2011-12-13 13:53:12 +0000 |
511 | +++ lib/lp/registry/configure.zcml 2011-12-14 03:28:31 +0000 |
512 | @@ -912,8 +912,6 @@ |
513 | <allow |
514 | interface="lp.registry.interfaces.person.IPersonPublic"/> |
515 | <allow |
516 | - interface="lp.registry.interfaces.person.ITeamPublic"/> |
517 | - <allow |
518 | interface="lp.registry.interfaces.person.IHasStanding"/> |
519 | <allow |
520 | interface="lp.registry.interfaces.person.IPersonCommAdminWriteRestricted"/> |
521 | @@ -924,6 +922,9 @@ |
522 | permission="launchpad.View" |
523 | interface="lp.registry.interfaces.person.IPersonViewRestricted"/> |
524 | <require |
525 | + permission="launchpad.View" |
526 | + interface="lp.registry.interfaces.person.ITeamPublic"/> |
527 | + <require |
528 | permission="launchpad.Edit" |
529 | interface="lp.registry.interfaces.location.ISetLocation"/> |
530 | <require |
531 | @@ -931,9 +932,10 @@ |
532 | interface="lp.registry.interfaces.person.IPersonEditRestricted"/> |
533 | <require |
534 | permission="launchpad.Edit" |
535 | - set_schema="lp.registry.interfaces.person.IPersonPublic |
536 | + set_schema="lp.registry.interfaces.person.IPersonViewRestricted |
537 | + lp.registry.interfaces.person.IPersonPublic |
538 | lp.registry.interfaces.person.ITeamPublic" |
539 | - set_attributes="displayname"/> |
540 | + set_attributes="displayname icon logo"/> |
541 | <require |
542 | permission="launchpad.Moderate" |
543 | set_attributes="name"/> |
544 | |
545 | === modified file 'lib/lp/registry/doc/private-team-visibility.txt' |
546 | --- lib/lp/registry/doc/private-team-visibility.txt 2011-12-13 13:53:12 +0000 |
547 | +++ lib/lp/registry/doc/private-team-visibility.txt 2011-12-14 03:28:31 +0000 |
548 | @@ -107,3 +107,64 @@ |
549 | ... |
550 | Unauthorized: (<Person at ... priv-team (Priv Team)>, |
551 | 'name', 'launchpad.LimitedView') |
552 | + |
553 | +A person with visibility to any of the branches owned by the private team will |
554 | +be granted limited view permission on the team. |
555 | + |
556 | +For private branches, a user needs to be subscribed to the branch for the |
557 | +branch (and hence team) to be visible. |
558 | + |
559 | + >>> login_person(priv_owner) |
560 | + >>> private_team_branch = factory.makeBranch( |
561 | + ... owner=priv_team, private=True) |
562 | + >>> login_person(pub_member) |
563 | + >>> check_permission('launchpad.LimitedView', priv_team) |
564 | + False |
565 | + |
566 | + >>> login_person(priv_owner) |
567 | + >>> sub = factory.makeBranchSubscription( |
568 | + ... branch=private_team_branch, person=pub_member, |
569 | + ... subscribed_by=priv_owner) |
570 | + |
571 | + >>> login_person(pub_member) |
572 | + >>> check_permission('launchpad.LimitedView', priv_team) |
573 | + True |
574 | + |
575 | +Subscribers to the teams private PPA have limited view permission. |
576 | + |
577 | + >>> login_person(priv_owner) |
578 | + >>> archive = factory.makeArchive(private=True, owner=priv_team) |
579 | + >>> archive_subscriber = factory.makePerson() |
580 | + >>> sub = archive.newSubscription( |
581 | + ... archive_subscriber, registrant=archive.owner) |
582 | + |
583 | + >>> login_person(archive_subscriber) |
584 | + >>> check_permission('launchpad.LimitedView', priv_team) |
585 | + True |
586 | + |
587 | +Users with LimitedView can know identifying information like name, |
588 | +displayname, and unique_name, but cannot know other information like |
589 | +teamowner. |
590 | + |
591 | + >>> print priv_team.name |
592 | + priv-team |
593 | + |
594 | + >>> print priv_team.displayname |
595 | + Priv Team |
596 | + |
597 | + >>> print priv_team.unique_displayname |
598 | + Priv Team (priv-team) |
599 | + |
600 | + >>> print priv_team.icon |
601 | + None |
602 | + |
603 | + >>> print priv_team.allmembers |
604 | + Traceback (most recent call last): |
605 | + ... |
606 | + Unauthorized: (<Person at ... priv-team (Priv Team)>, |
607 | + 'allmembers', 'launchpad.View') |
608 | + |
609 | +Anonymous users do not have permission. |
610 | + >>> login(ANONYMOUS) |
611 | + >>> check_permission('launchpad.LimitedView', priv_team) |
612 | + False |
613 | |
614 | === modified file 'lib/lp/registry/interfaces/person.py' |
615 | --- lib/lp/registry/interfaces/person.py 2011-12-13 13:53:12 +0000 |
616 | +++ lib/lp/registry/interfaces/person.py 2011-12-14 03:28:31 +0000 |
617 | @@ -15,7 +15,7 @@ |
618 | 'IObjectReassignment', |
619 | 'IPerson', |
620 | 'IPersonClaim', |
621 | - 'IPersonPublic', # Required for a monkey patch in interfaces/archive.py |
622 | + 'IPersonPublic', |
623 | 'IPersonSet', |
624 | 'IPersonSettings', |
625 | 'ISoftwareCenterAgentAPI', |
626 | @@ -657,26 +657,61 @@ |
627 | required=False, default=False) |
628 | |
629 | |
630 | -class IPersonPublic(IHasBranches, IHasSpecifications, |
631 | - IHasMergeProposals, IHasLogo, IHasMugshot, IHasIcon, |
632 | - IHasLocation, IHasRequestedReviews, IObjectWithLocation, |
633 | - IPrivacy, IHasBugs, IHasRecipes, IHasTranslationImports, |
634 | - IPersonSettings, IQuestionsPerson): |
635 | - """Public attributes for a Person.""" |
636 | +class IPersonPublic(IPrivacy): |
637 | + """Public attributes for a Person. |
638 | + |
639 | + Very few attributes on a person can be public because private teams |
640 | + are also persons. The public attributes are generally information |
641 | + needed by the system to determine if the principal in the current |
642 | + interaction can work with the object. |
643 | + """ |
644 | |
645 | id = Int(title=_('ID'), required=True, readonly=True) |
646 | - account = Object(schema=IAccount) |
647 | - accountID = Int(title=_('Account ID'), required=True, readonly=True) |
648 | - password = PasswordField( |
649 | - title=_('Password'), required=True, readonly=False) |
650 | - karma = exported( |
651 | - Int(title=_('Karma'), readonly=True, |
652 | - description=_('The cached total karma for this person.'))) |
653 | - homepage_content = exported( |
654 | - Text(title=_("Homepage Content"), required=False, |
655 | - description=_( |
656 | - "The content of your profile page. Use plain text, " |
657 | - "paragraphs are preserved and URLs are linked in pages."))) |
658 | + # This is redefined from IPrivacy.private because the attribute is |
659 | + # read-only. It is a summary of the team's visibility. |
660 | + private = exported(Bool( |
661 | + title=_("This team is private"), |
662 | + readonly=True, required=False, |
663 | + description=_("Private teams are visible only to " |
664 | + "their members."))) |
665 | + is_valid_person = Bool( |
666 | + title=_("This is an active user and not a team."), readonly=True) |
667 | + is_valid_person_or_team = exported( |
668 | + Bool(title=_("This is an active user or a team."), readonly=True), |
669 | + exported_as='is_valid') |
670 | + is_merge_pending = exported(Bool( |
671 | + title=_("Is this person due to be merged with another?"), |
672 | + required=False, default=False)) |
673 | + is_team = exported( |
674 | + Bool(title=_('Is this object a team?'), readonly=True)) |
675 | + account_status = Choice( |
676 | + title=_("The status of this person's account"), required=False, |
677 | + readonly=True, vocabulary=AccountStatus) |
678 | + account_status_comment = Text( |
679 | + title=_("Why are you deactivating your account?"), required=False, |
680 | + readonly=True) |
681 | + |
682 | + |
683 | +class IPersonLimitedView(IHasIcon, IHasLogo): |
684 | + """IPerson attributes that require launchpad.LimitedView permission.""" |
685 | + |
686 | + name = exported( |
687 | + PersonNameField( |
688 | + title=_('Name'), required=True, readonly=False, |
689 | + constraint=name_validator, |
690 | + description=_( |
691 | + "A short unique name, beginning with a lower-case " |
692 | + "letter or number, and containing only letters, " |
693 | + "numbers, dots, hyphens, or plus signs."))) |
694 | + displayname = exported( |
695 | + StrippedTextLine( |
696 | + title=_('Display Name'), required=True, readonly=False, |
697 | + description=_( |
698 | + "Your name as you would like it displayed throughout " |
699 | + "Launchpad. Most people use their full name here.")), |
700 | + exported_as='display_name') |
701 | + unique_displayname = TextLine( |
702 | + title=_('Return a string of the form $displayname ($name).')) |
703 | # NB at this stage we do not allow individual people to have their own |
704 | # icon, only teams get that. People can however have a logo and mugshot |
705 | # The icon is only used for teams; that's why we use /@@/team as the |
706 | @@ -690,7 +725,6 @@ |
707 | "displayed whenever the team name is listed - for example " |
708 | "in listings of bugs or on a person's membership table.")) |
709 | iconID = Int(title=_('Icon ID'), required=True, readonly=True) |
710 | - |
711 | logo = exported( |
712 | LogoImageUpload( |
713 | title=_("Logo"), required=False, |
714 | @@ -701,6 +735,44 @@ |
715 | "is a logo, a small picture or a personal mascot. It should " |
716 | "be no bigger than 50kb in size."))) |
717 | logoID = Int(title=_('Logo ID'), required=True, readonly=True) |
718 | + # title is required for the Launchpad Page Layout main template |
719 | + title = Attribute('Person Page Title') |
720 | + is_probationary = exported( |
721 | + Bool(title=_("Is this a probationary user?"), readonly=True)) |
722 | + |
723 | + @operation_parameters( |
724 | + name=TextLine(required=True, constraint=name_validator)) |
725 | + @operation_returns_entry(Interface) # Really IArchive. |
726 | + @export_read_operation() |
727 | + @operation_for_version("beta") |
728 | + def getPPAByName(name): |
729 | + """Return a PPA with the given name if it exists. |
730 | + |
731 | + :param name: A string with the exact name of the ppa being looked up. |
732 | + :raises: `NoSuchPPA` if a suitable PPA could not be found. |
733 | + |
734 | + :return: a PPA `IArchive` record corresponding to the name. |
735 | + """ |
736 | + |
737 | + |
738 | +class IPersonViewRestricted(IHasBranches, IHasSpecifications, |
739 | + IHasMergeProposals, IHasMugshot, |
740 | + IHasLocation, IHasRequestedReviews, IObjectWithLocation, |
741 | + IHasBugs, IHasRecipes, IHasTranslationImports, |
742 | + IPersonSettings, IQuestionsPerson): |
743 | + """IPerson attributes that require launchpad.View permission.""" |
744 | + account = Object(schema=IAccount) |
745 | + accountID = Int(title=_('Account ID'), required=True, readonly=True) |
746 | + password = PasswordField( |
747 | + title=_('Password'), required=True, readonly=False) |
748 | + karma = exported( |
749 | + Int(title=_('Karma'), readonly=True, |
750 | + description=_('The cached total karma for this person.'))) |
751 | + homepage_content = exported( |
752 | + Text(title=_("Homepage Content"), required=False, |
753 | + description=_( |
754 | + "The content of your profile page. Use plain text, " |
755 | + "paragraphs are preserved and URLs are linked in pages."))) |
756 | |
757 | mugshot = exported(MugshotImageUpload( |
758 | title=_("Mugshot"), required=False, |
759 | @@ -755,26 +827,9 @@ |
760 | readonly=False, required=False, |
761 | value_type=Reference(schema=ISSHKey))) |
762 | |
763 | - account_status = Choice( |
764 | - title=_("The status of this person's account"), required=False, |
765 | - readonly=True, vocabulary=AccountStatus) |
766 | - |
767 | - account_status_comment = Text( |
768 | - title=_("Why are you deactivating your account?"), required=False, |
769 | - readonly=True) |
770 | - |
771 | # Properties of the Person object. |
772 | karma_category_caches = Attribute( |
773 | 'The caches of karma scores, by karma category.') |
774 | - is_team = exported( |
775 | - Bool(title=_('Is this object a team?'), readonly=True)) |
776 | - is_valid_person = Bool( |
777 | - title=_("This is an active user and not a team."), readonly=True) |
778 | - is_valid_person_or_team = exported( |
779 | - Bool(title=_("This is an active user or a team."), readonly=True), |
780 | - exported_as='is_valid') |
781 | - is_probationary = exported( |
782 | - Bool(title=_("Is this a probationary user?"), readonly=True)) |
783 | is_ubuntu_coc_signer = exported( |
784 | Bool(title=_("Signed Ubuntu Code of Conduct"), |
785 | readonly=True)) |
786 | @@ -879,7 +934,6 @@ |
787 | exported_as='team_owner') |
788 | teamownerID = Int(title=_("The Team Owner's ID or None"), required=False, |
789 | readonly=True) |
790 | - |
791 | preferredemail = exported( |
792 | Reference(title=_("Preferred email address"), |
793 | description=_("The preferred email address for this person. " |
794 | @@ -916,9 +970,6 @@ |
795 | "this is set to None, then this Person has not been merged " |
796 | "into another and is still valid")) |
797 | |
798 | - # title is required for the Launchpad Page Layout main template |
799 | - title = Attribute('Person Page Title') |
800 | - |
801 | archive = exported( |
802 | Reference( |
803 | title=_("Default PPA"), |
804 | @@ -987,18 +1038,6 @@ |
805 | readonly=True, required=False, |
806 | value_type=Reference(schema=Interface))) # HWSubmission |
807 | |
808 | - # This is redefined from IPrivacy.private because the attribute is |
809 | - # read-only. It is a summary of the team's visibility. |
810 | - private = exported(Bool( |
811 | - title=_("This team is private"), |
812 | - readonly=True, required=False, |
813 | - description=_("Private teams are visible only to " |
814 | - "their members."))) |
815 | - |
816 | - is_merge_pending = exported(Bool( |
817 | - title=_("Is this person due to be merged with another?"), |
818 | - required=False, default=False)) |
819 | - |
820 | administrated_teams = Attribute( |
821 | u"the teams that this person/team is an administrator of.") |
822 | |
823 | @@ -1414,20 +1453,6 @@ |
824 | """ |
825 | |
826 | @operation_parameters( |
827 | - name=TextLine(required=True, constraint=name_validator)) |
828 | - @operation_returns_entry(Interface) # Really IArchive. |
829 | - @export_read_operation() |
830 | - @operation_for_version("beta") |
831 | - def getPPAByName(name): |
832 | - """Return a PPA with the given name if it exists. |
833 | - |
834 | - :param name: A string with the exact name of the ppa being looked up. |
835 | - :raises: `NoSuchPPA` if a suitable PPA could not be found. |
836 | - |
837 | - :return: a PPA `IArchive` record corresponding to the name. |
838 | - """ |
839 | - |
840 | - @operation_parameters( |
841 | name=TextLine(required=True, constraint=name_validator), |
842 | displayname=TextLine(required=False), |
843 | description=TextLine(required=False), |
844 | @@ -1463,32 +1488,6 @@ |
845 | :return: a boolean. |
846 | """ |
847 | |
848 | - |
849 | -class IPersonLimitedView(Interface): |
850 | - """IPerson attributes that require launchpad.LimitedView permission.""" |
851 | - |
852 | - name = exported( |
853 | - PersonNameField( |
854 | - title=_('Name'), required=True, readonly=False, |
855 | - constraint=name_validator, |
856 | - description=_( |
857 | - "A short unique name, beginning with a lower-case " |
858 | - "letter or number, and containing only letters, " |
859 | - "numbers, dots, hyphens, or plus signs."))) |
860 | - displayname = exported( |
861 | - StrippedTextLine( |
862 | - title=_('Display Name'), required=True, readonly=False, |
863 | - description=_( |
864 | - "Your name as you would like it displayed throughout " |
865 | - "Launchpad. Most people use their full name here.")), |
866 | - exported_as='display_name') |
867 | - unique_displayname = TextLine( |
868 | - title=_('Return a string of the form $displayname ($name).')) |
869 | - |
870 | - |
871 | -class IPersonViewRestricted(Interface): |
872 | - """IPerson attributes that require launchpad.View permission.""" |
873 | - |
874 | active_member_count = Attribute( |
875 | "The number of real people who are members of this team.") |
876 | # activemembers.value_type.schema will be set to IPerson once |
877 | @@ -2565,18 +2564,19 @@ |
878 | ]: |
879 | IPersonViewRestricted[name].value_type.schema = IPerson |
880 | |
881 | -IPersonPublic['sub_teams'].value_type.schema = ITeam |
882 | -IPersonPublic['super_teams'].value_type.schema = ITeam |
883 | +IPersonViewRestricted['sub_teams'].value_type.schema = ITeam |
884 | +IPersonViewRestricted['super_teams'].value_type.schema = ITeam |
885 | # XXX: salgado, 2008-08-01: Uncomment these when teams_*participated_in are |
886 | # exported again. |
887 | -# IPersonPublic['teams_participated_in'].value_type.schema = ITeam |
888 | -# IPersonPublic['teams_indirectly_participated_in'].value_type.schema = ITeam |
889 | +# IPersonViewRestricted['teams_participated_in'].value_type.schema = ITeam |
890 | +# IPersonViewRestricted[ |
891 | +# 'teams_indirectly_participated_in'].value_type.schema = ITeam |
892 | |
893 | # Fix schema of operation parameters. We need zope.deferredimport! |
894 | params_to_fix = [ |
895 | # XXX: salgado, 2008-08-01: Uncomment these when they are exported again. |
896 | - # (IPersonPublic['findPathToTeam'], 'team'), |
897 | - # (IPersonPublic['inTeam'], 'team'), |
898 | + # (IPersonViewRestricted['findPathToTeam'], 'team'), |
899 | + # (IPersonViewRestricted['inTeam'], 'team'), |
900 | (IPersonEditRestricted['join'], 'team'), |
901 | (IPersonEditRestricted['leave'], 'team'), |
902 | (IPersonEditRestricted['addMember'], 'person'), |
903 | |
904 | === modified file 'lib/lp/registry/interfaces/role.py' |
905 | --- lib/lp/registry/interfaces/role.py 2011-12-13 13:53:12 +0000 |
906 | +++ lib/lp/registry/interfaces/role.py 2011-12-14 03:28:31 +0000 |
907 | @@ -119,7 +119,8 @@ |
908 | def inTeam(team): |
909 | """Is this person a member or the owner of `team`? |
910 | |
911 | - Passed through to the same method in 'IPersonPublic'. |
912 | + Passed through to the *unproxied* same method in |
913 | + `IPersonViewRestricted`. |
914 | """ |
915 | |
916 | def isOwner(obj): |
917 | |
918 | === modified file 'lib/lp/soyuz/tests/test_archive_subscriptions.py' |
919 | --- lib/lp/soyuz/tests/test_archive_subscriptions.py 2011-12-13 18:05:18 +0000 |
920 | +++ lib/lp/soyuz/tests/test_archive_subscriptions.py 2011-12-14 03:28:31 +0000 |
921 | @@ -5,14 +5,21 @@ |
922 | |
923 | from zope.security.interfaces import Unauthorized |
924 | |
925 | +from canonical.launchpad.testing.pages import ( |
926 | + find_tag_by_id, |
927 | + setupBrowserForUser |
928 | + ) |
929 | +from canonical.launchpad.webapp.publisher import canonical_url |
930 | from canonical.testing.layers import DatabaseFunctionalLayer |
931 | from lp.registry.interfaces.person import PersonVisibility |
932 | from lp.testing import ( |
933 | celebrity_logged_in, |
934 | login_person, |
935 | + person_logged_in, |
936 | TestCaseWithFactory, |
937 | ) |
938 | from lp.testing.mail_helpers import pop_notifications |
939 | +from lp.testing import BrowserTestCase |
940 | from lp.testing.views import create_initialized_view |
941 | |
942 | |
943 | @@ -111,3 +118,35 @@ |
944 | self.subscriber, registrant=self.archive.owner) |
945 | |
946 | self.assertEqual(0, len(pop_notifications())) |
947 | + |
948 | + |
949 | +class PrivateArtifactsViewTestCase(BrowserTestCase): |
950 | + """ Tests that private team archives can be viewed.""" |
951 | + |
952 | + layer = DatabaseFunctionalLayer |
953 | + |
954 | + def setUp(self): |
955 | + """Create a test archive.""" |
956 | + super(PrivateArtifactsViewTestCase, self).setUp() |
957 | + self.owner = self.factory.makePerson() |
958 | + self.private_team = self.factory.makeTeam( |
959 | + visibility=PersonVisibility.PRIVATE, |
960 | + name="subscribertest", owner=self.owner) |
961 | + with person_logged_in(self.owner): |
962 | + self.archive = self.factory.makeArchive( |
963 | + private=True, owner=self.private_team) |
964 | + self.subscriber = self.factory.makePerson() |
965 | + |
966 | + def test_traverse_view_private_team_archive_subscriber(self): |
967 | + # A subscriber can traverse and view the archive. |
968 | + with person_logged_in(self.owner): |
969 | + self.archive.newSubscription( |
970 | + self.subscriber, registrant=self.archive.owner) |
971 | + with person_logged_in(self.subscriber): |
972 | + url = canonical_url(self.archive) |
973 | + browser = setupBrowserForUser(self.subscriber) |
974 | + browser.open(url) |
975 | + content = find_tag_by_id(browser.contents, 'document') |
976 | + self.assertIsNotNone(find_tag_by_id(content, 'ppa-install')) |
977 | + self.assertIsNotNone( |
978 | + find_tag_by_id(content, 'portlet-latest-updates')) |
Hi Steve.
Can we put this branch on dogfood or staging so that we can see how this behaves with production data?