Merge lp:~cjwatson/launchpad/git-xmlrpc into lp:launchpad
- git-xmlrpc
- Merge into devel
Proposed by
Colin Watson
on 2015-03-03
Status: | Merged |
---|---|
Merged at revision: | 17373 |
Proposed branch: | lp:~cjwatson/launchpad/git-xmlrpc |
Merge into: | lp:launchpad |
Diff against target: |
1255 lines (+1007/-7) 17 files modified
lib/lp/code/errors.py (+1/-1) lib/lp/code/githosting.py (+52/-0) lib/lp/code/interfaces/gitapi.py (+51/-0) lib/lp/code/interfaces/gitnamespace.py (+7/-0) lib/lp/code/interfaces/gitrepository.py (+1/-0) lib/lp/code/model/gitlookup.py (+2/-0) lib/lp/code/model/gitnamespace.py (+9/-0) lib/lp/code/model/tests/test_gitlookup.py (+6/-0) lib/lp/code/xmlrpc/codehosting.py (+2/-1) lib/lp/code/xmlrpc/git.py (+234/-0) lib/lp/code/xmlrpc/tests/test_git.py (+584/-0) lib/lp/systemhomes.py (+8/-1) lib/lp/xmlrpc/application.py (+7/-1) lib/lp/xmlrpc/configure.zcml (+18/-1) lib/lp/xmlrpc/faults.py (+21/-1) lib/lp/xmlrpc/interfaces.py (+3/-1) setup.py (+1/-0) |
To merge this branch: | bzr merge lp:~cjwatson/launchpad/git-xmlrpc |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | 2015-03-03 | Approve on 2015-03-04 |
Review via email:
|
Commit message
Add a private XML-RPC endpoint for Git-related operations needed by the hosting service.
Description of the change
Add a private XML-RPC endpoint for Git-related operations needed by the hosting service.
I noticed a bug in GitLookup along the way, and there's a subtlety in getting hold of the hosting path because it relies on the SERIAL id column, so we have to use currval to grab that before committing the transaction in order that we can roll back the GitRepository row creation if we fail to create the repository on the hosting service.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/code/errors.py' |
2 | --- lib/lp/code/errors.py 2015-02-26 12:10:20 +0000 |
3 | +++ lib/lp/code/errors.py 2015-03-04 11:12:53 +0000 |
4 | @@ -367,7 +367,7 @@ |
5 | """ |
6 | |
7 | |
8 | -class GitRepositoryCreationFault(GitRepositoryCreationException): |
9 | +class GitRepositoryCreationFault(Exception): |
10 | """Raised when there is a hosting fault creating a Git repository.""" |
11 | |
12 | |
13 | |
14 | === added file 'lib/lp/code/githosting.py' |
15 | --- lib/lp/code/githosting.py 1970-01-01 00:00:00 +0000 |
16 | +++ lib/lp/code/githosting.py 2015-03-04 11:12:53 +0000 |
17 | @@ -0,0 +1,52 @@ |
18 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
19 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
20 | + |
21 | +"""Communication with the Git hosting service.""" |
22 | + |
23 | +__metaclass__ = type |
24 | +__all__ = [ |
25 | + 'GitHostingClient', |
26 | + ] |
27 | + |
28 | +import json |
29 | +from urlparse import urljoin |
30 | + |
31 | +import requests |
32 | + |
33 | +from lp.code.errors import GitRepositoryCreationFault |
34 | + |
35 | + |
36 | +class GitHostingClient: |
37 | + """A client for the internal API provided by the Git hosting system.""" |
38 | + |
39 | + def __init__(self, endpoint): |
40 | + self.endpoint = endpoint |
41 | + |
42 | + def _makeSession(self): |
43 | + session = requests.Session() |
44 | + session.trust_env = False |
45 | + return session |
46 | + |
47 | + @property |
48 | + def timeout(self): |
49 | + # XXX cjwatson 2015-03-01: The hardcoded timeout at least means that |
50 | + # we don't lock tables indefinitely if the hosting service falls |
51 | + # over, but is there some more robust way to do this? |
52 | + return 5.0 |
53 | + |
54 | + def create(self, path): |
55 | + try: |
56 | + # XXX cjwatson 2015-03-01: Once we're on requests >= 2.4.2, we |
57 | + # should just use post(json=) and drop the explicit Content-Type |
58 | + # header. |
59 | + response = self._makeSession().post( |
60 | + urljoin(self.endpoint, "repo"), |
61 | + headers={"Content-Type": "application/json"}, |
62 | + data=json.dumps({"repo_path": path, "bare_repo": True}), |
63 | + timeout=self.timeout) |
64 | + except Exception as e: |
65 | + raise GitRepositoryCreationFault( |
66 | + "Failed to create Git repository: %s" % unicode(e)) |
67 | + if response.status_code != 200: |
68 | + raise GitRepositoryCreationFault( |
69 | + "Failed to create Git repository: %s" % response.text) |
70 | |
71 | === added file 'lib/lp/code/interfaces/gitapi.py' |
72 | --- lib/lp/code/interfaces/gitapi.py 1970-01-01 00:00:00 +0000 |
73 | +++ lib/lp/code/interfaces/gitapi.py 2015-03-04 11:12:53 +0000 |
74 | @@ -0,0 +1,51 @@ |
75 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
76 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
77 | + |
78 | +"""Interfaces for internal Git APIs.""" |
79 | + |
80 | +__metaclass__ = type |
81 | +__all__ = [ |
82 | + 'IGitAPI', |
83 | + 'IGitApplication', |
84 | + ] |
85 | + |
86 | +from zope.interface import Interface |
87 | + |
88 | +from lp.services.webapp.interfaces import ILaunchpadApplication |
89 | + |
90 | + |
91 | +class IGitApplication(ILaunchpadApplication): |
92 | + """Git application root.""" |
93 | + |
94 | + |
95 | +class IGitAPI(Interface): |
96 | + """The Git XML-RPC interface to Launchpad. |
97 | + |
98 | + Published at "git" on the private XML-RPC server. |
99 | + |
100 | + The Git pack frontend uses this to translate user-visible paths to |
101 | + internal ones, and to notify Launchpad of ref changes. |
102 | + """ |
103 | + |
104 | + def translatePath(path, permission, requester_id, can_authenticate): |
105 | + """Translate 'path' so that the Git pack frontend can access it. |
106 | + |
107 | + If the repository does not exist and write permission was requested, |
108 | + register a new repository if possible. |
109 | + |
110 | + :param path: The path being translated. This should be a string |
111 | + representing an absolute path to a Git repository. |
112 | + :param permission: "read" or "write". |
113 | + :param requester_id: The database ID of the person requesting the |
114 | + path translation, or None for an anonymous request. |
115 | + :param can_authenticate: True if the frontend can request |
116 | + authentication, otherwise False. |
117 | + |
118 | + :returns: A `PathTranslationError` fault if 'path' cannot be |
119 | + translated; a `PermissionDenied` fault if the requester cannot |
120 | + see or create the repository; otherwise, a dict containing at |
121 | + least the following keys:: |
122 | + "path", whose value is the repository's storage path; |
123 | + "writable", whose value is True if the requester can push to |
124 | + this repository, otherwise False. |
125 | + """ |
126 | |
127 | === modified file 'lib/lp/code/interfaces/gitnamespace.py' |
128 | --- lib/lp/code/interfaces/gitnamespace.py 2015-02-26 17:16:57 +0000 |
129 | +++ lib/lp/code/interfaces/gitnamespace.py 2015-03-04 11:12:53 +0000 |
130 | @@ -86,6 +86,13 @@ |
131 | class IGitNamespacePolicy(Interface): |
132 | """Methods relating to Git repository creation and validation.""" |
133 | |
134 | + has_defaults = Attribute( |
135 | + "True iff the target of this namespace may have a default repository.") |
136 | + |
137 | + allow_push_to_set_default = Attribute( |
138 | + "True iff this namespace permits automatically setting a default " |
139 | + "repository on push.") |
140 | + |
141 | def getAllowedInformationTypes(who): |
142 | """Get the information types that a repository in this namespace can |
143 | have. |
144 | |
145 | === modified file 'lib/lp/code/interfaces/gitrepository.py' |
146 | --- lib/lp/code/interfaces/gitrepository.py 2015-02-26 11:34:47 +0000 |
147 | +++ lib/lp/code/interfaces/gitrepository.py 2015-03-04 11:12:53 +0000 |
148 | @@ -7,6 +7,7 @@ |
149 | |
150 | __all__ = [ |
151 | 'GitIdentityMixin', |
152 | + 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE', |
153 | 'git_repository_name_validator', |
154 | 'IGitRepository', |
155 | 'IGitRepositorySet', |
156 | |
157 | === modified file 'lib/lp/code/model/gitlookup.py' |
158 | --- lib/lp/code/model/gitlookup.py 2015-02-27 10:22:24 +0000 |
159 | +++ lib/lp/code/model/gitlookup.py 2015-03-04 11:12:53 +0000 |
160 | @@ -342,6 +342,8 @@ |
161 | return None |
162 | if repository is not None: |
163 | return repository |
164 | + if IPerson.providedBy(target): |
165 | + return None |
166 | repository_set = getUtility(IGitRepositorySet) |
167 | if owner is None: |
168 | return repository_set.getDefaultRepository(target) |
169 | |
170 | === modified file 'lib/lp/code/model/gitnamespace.py' |
171 | --- lib/lp/code/model/gitnamespace.py 2015-02-26 17:16:57 +0000 |
172 | +++ lib/lp/code/model/gitnamespace.py 2015-03-04 11:12:53 +0000 |
173 | @@ -190,6 +190,9 @@ |
174 | |
175 | implements(IGitNamespace, IGitNamespacePolicy) |
176 | |
177 | + has_defaults = False |
178 | + allow_push_to_set_default = False |
179 | + |
180 | def __init__(self, person): |
181 | self.owner = person |
182 | |
183 | @@ -247,6 +250,9 @@ |
184 | |
185 | implements(IGitNamespace, IGitNamespacePolicy) |
186 | |
187 | + has_defaults = True |
188 | + allow_push_to_set_default = True |
189 | + |
190 | def __init__(self, person, project): |
191 | self.owner = person |
192 | self.project = project |
193 | @@ -307,6 +313,9 @@ |
194 | |
195 | implements(IGitNamespace, IGitNamespacePolicy) |
196 | |
197 | + has_defaults = True |
198 | + allow_push_to_set_default = False |
199 | + |
200 | def __init__(self, person, distro_source_package): |
201 | self.owner = person |
202 | self.distro_source_package = distro_source_package |
203 | |
204 | === modified file 'lib/lp/code/model/tests/test_gitlookup.py' |
205 | --- lib/lp/code/model/tests/test_gitlookup.py 2015-02-27 10:22:24 +0000 |
206 | +++ lib/lp/code/model/tests/test_gitlookup.py 2015-03-04 11:12:53 +0000 |
207 | @@ -117,6 +117,12 @@ |
208 | project = self.factory.makeProduct() |
209 | self.assertIsNone(self.lookup.getByPath(project.name)) |
210 | |
211 | + def test_bare_person(self): |
212 | + # If `getByPath` is given a path to a person but nothing further, it |
213 | + # returns None even if the person exists. |
214 | + owner = self.factory.makePerson() |
215 | + self.assertIsNone(self.lookup.getByPath("~%s" % owner.name)) |
216 | + |
217 | |
218 | class TestGetByUrl(TestCaseWithFactory): |
219 | """Test `IGitLookup.getByUrl`.""" |
220 | |
221 | === modified file 'lib/lp/code/xmlrpc/codehosting.py' |
222 | --- lib/lp/code/xmlrpc/codehosting.py 2012-11-26 08:33:03 +0000 |
223 | +++ lib/lp/code/xmlrpc/codehosting.py 2015-03-04 11:12:53 +0000 |
224 | @@ -1,4 +1,4 @@ |
225 | -# Copyright 2009-2012 Canonical Ltd. This software is licensed under the |
226 | +# Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
227 | # GNU Affero General Public License version 3 (see the file LICENSE). |
228 | |
229 | """Implementations of the XML-RPC APIs for codehosting.""" |
230 | @@ -7,6 +7,7 @@ |
231 | __all__ = [ |
232 | 'CodehostingAPI', |
233 | 'datetime_from_tuple', |
234 | + 'run_with_login', |
235 | ] |
236 | |
237 | |
238 | |
239 | === added file 'lib/lp/code/xmlrpc/git.py' |
240 | --- lib/lp/code/xmlrpc/git.py 1970-01-01 00:00:00 +0000 |
241 | +++ lib/lp/code/xmlrpc/git.py 2015-03-04 11:12:53 +0000 |
242 | @@ -0,0 +1,234 @@ |
243 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
244 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
245 | + |
246 | +"""Implementations of the XML-RPC APIs for Git.""" |
247 | + |
248 | +__metaclass__ = type |
249 | +__all__ = [ |
250 | + 'GitAPI', |
251 | + ] |
252 | + |
253 | +import sys |
254 | + |
255 | +from storm.store import Store |
256 | +import transaction |
257 | +from zope.component import getUtility |
258 | +from zope.error.interfaces import IErrorReportingUtility |
259 | +from zope.interface import implements |
260 | +from zope.security.interfaces import Unauthorized |
261 | + |
262 | +from lp.app.errors import NameLookupFailed |
263 | +from lp.app.validators import LaunchpadValidationError |
264 | +from lp.code.errors import ( |
265 | + GitRepositoryCreationException, |
266 | + GitRepositoryCreationForbidden, |
267 | + GitRepositoryCreationFault, |
268 | + GitRepositoryExists, |
269 | + InvalidNamespace, |
270 | + ) |
271 | +from lp.code.githosting import GitHostingClient |
272 | +from lp.code.interfaces.codehosting import LAUNCHPAD_ANONYMOUS |
273 | +from lp.code.interfaces.gitapi import IGitAPI |
274 | +from lp.code.interfaces.gitlookup import ( |
275 | + IGitLookup, |
276 | + IGitTraverser, |
277 | + ) |
278 | +from lp.code.interfaces.gitnamespace import ( |
279 | + get_git_namespace, |
280 | + split_git_unique_name, |
281 | + ) |
282 | +from lp.code.interfaces.gitrepository import IGitRepositorySet |
283 | +from lp.code.xmlrpc.codehosting import run_with_login |
284 | +from lp.registry.errors import ( |
285 | + InvalidName, |
286 | + NoSuchSourcePackageName, |
287 | + ) |
288 | +from lp.registry.interfaces.person import NoSuchPerson |
289 | +from lp.registry.interfaces.product import ( |
290 | + InvalidProductName, |
291 | + NoSuchProduct, |
292 | + ) |
293 | +from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet |
294 | +from lp.services.config import config |
295 | +from lp.services.webapp import LaunchpadXMLRPCView |
296 | +from lp.services.webapp.authorization import check_permission |
297 | +from lp.services.webapp.errorlog import ScriptRequest |
298 | +from lp.xmlrpc import faults |
299 | +from lp.xmlrpc.helpers import return_fault |
300 | + |
301 | + |
302 | +class GitAPI(LaunchpadXMLRPCView): |
303 | + """See `IGitAPI`.""" |
304 | + |
305 | + implements(IGitAPI) |
306 | + |
307 | + def __init__(self, *args, **kwargs): |
308 | + super(GitAPI, self).__init__(*args, **kwargs) |
309 | + self.hosting_client = GitHostingClient( |
310 | + config.codehosting.internal_git_api_endpoint) |
311 | + |
312 | + def _performLookup(self, path): |
313 | + repository = getUtility(IGitLookup).getByPath(path) |
314 | + if repository is None: |
315 | + return None |
316 | + try: |
317 | + hosting_path = repository.getInternalPath() |
318 | + except Unauthorized: |
319 | + raise faults.PermissionDenied() |
320 | + writable = check_permission("launchpad.Edit", repository) |
321 | + return {"path": hosting_path, "writable": writable} |
322 | + |
323 | + def _getGitNamespaceExtras(self, path, requester): |
324 | + """Get the namespace, repository name, and callback for the path. |
325 | + |
326 | + If the path defines a full Git repository path including the owner |
327 | + and repository name, then the namespace that is returned is the |
328 | + namespace for the owner and the repository target specified. |
329 | + |
330 | + If the path uses a shortcut name, then we only allow the requester |
331 | + to create a repository if they have permission to make the newly |
332 | + created repository the default for the shortcut target. If there is |
333 | + an existing default repository, then GitRepositoryExists is raised. |
334 | + The repository name that is used is determined by the namespace as |
335 | + the first unused name starting with the leaf part of the namespace |
336 | + name. In this case, the repository owner will be set to the |
337 | + namespace owner, and distribution source package namespaces are |
338 | + currently disallowed due to the complexities of ownership there. |
339 | + """ |
340 | + try: |
341 | + namespace_name, repository_name = split_git_unique_name(path) |
342 | + except InvalidNamespace: |
343 | + namespace_name = path |
344 | + repository_name = None |
345 | + owner, target, repository = getUtility(IGitTraverser).traverse_path( |
346 | + namespace_name) |
347 | + # split_git_unique_name should have left us without a repository name. |
348 | + assert repository is None |
349 | + if owner is None: |
350 | + repository_owner = requester |
351 | + else: |
352 | + repository_owner = owner |
353 | + namespace = get_git_namespace(target, repository_owner) |
354 | + if repository_name is None and not namespace.has_defaults: |
355 | + raise InvalidNamespace(path) |
356 | + if owner is None and not namespace.allow_push_to_set_default: |
357 | + raise GitRepositoryCreationForbidden( |
358 | + "Cannot automatically set the default repository for this " |
359 | + "target; push to a named repository instead.") |
360 | + if repository_name is None: |
361 | + def default_func(new_repository): |
362 | + repository_set = getUtility(IGitRepositorySet) |
363 | + if owner is None: |
364 | + repository_set.setDefaultRepository( |
365 | + target, new_repository) |
366 | + else: |
367 | + repository_set.setDefaultRepositoryForOwner( |
368 | + owner, target, new_repository) |
369 | + |
370 | + repository_name = namespace.findUnusedName(target.name) |
371 | + return namespace, repository_name, default_func |
372 | + else: |
373 | + return namespace, repository_name, None |
374 | + |
375 | + def _reportError(self, path, exception, hosting_path=None): |
376 | + properties = [ |
377 | + ("path", path), |
378 | + ("error-explanation", unicode(exception)), |
379 | + ] |
380 | + if hosting_path is not None: |
381 | + properties.append(("hosting_path", hosting_path)) |
382 | + request = ScriptRequest(properties) |
383 | + getUtility(IErrorReportingUtility).raising(sys.exc_info(), request) |
384 | + raise faults.OopsOccurred("creating a Git repository", request.oopsid) |
385 | + |
386 | + def _createRepository(self, requester, path): |
387 | + try: |
388 | + namespace, repository_name, default_func = ( |
389 | + self._getGitNamespaceExtras(path, requester)) |
390 | + except InvalidNamespace: |
391 | + raise faults.PermissionDenied( |
392 | + "'%s' is not a valid Git repository path." % path) |
393 | + except NoSuchPerson as e: |
394 | + raise faults.NotFound("User/team '%s' does not exist." % e.name) |
395 | + except (NoSuchProduct, InvalidProductName) as e: |
396 | + raise faults.NotFound("Project '%s' does not exist." % e.name) |
397 | + except NoSuchSourcePackageName as e: |
398 | + try: |
399 | + getUtility(ISourcePackageNameSet).new(e.name) |
400 | + except InvalidName: |
401 | + raise faults.InvalidSourcePackageName(e.name) |
402 | + return self._createRepository(requester, path) |
403 | + except NameLookupFailed as e: |
404 | + raise faults.NotFound(unicode(e)) |
405 | + except GitRepositoryCreationForbidden as e: |
406 | + raise faults.PermissionDenied(unicode(e)) |
407 | + |
408 | + try: |
409 | + repository = namespace.createRepository( |
410 | + requester, repository_name) |
411 | + except LaunchpadValidationError as e: |
412 | + # Despite the fault name, this just passes through the exception |
413 | + # text so there's no need for a new Git-specific fault. |
414 | + raise faults.InvalidBranchName(e) |
415 | + except GitRepositoryExists as e: |
416 | + # We should never get here, as we just tried to translate the |
417 | + # path and found nothing (not even an inaccessible private |
418 | + # repository). Log an OOPS for investigation. |
419 | + self._reportError(path, e) |
420 | + except GitRepositoryCreationException as e: |
421 | + raise faults.PermissionDenied(unicode(e)) |
422 | + |
423 | + try: |
424 | + if default_func: |
425 | + try: |
426 | + default_func(repository) |
427 | + except Unauthorized: |
428 | + raise faults.PermissionDenied( |
429 | + "You cannot set the default Git repository for '%s'." % |
430 | + path) |
431 | + |
432 | + # Flush to make sure that repository.id is populated. |
433 | + Store.of(repository).flush() |
434 | + assert repository.id is not None |
435 | + |
436 | + hosting_path = repository.getInternalPath() |
437 | + try: |
438 | + self.hosting_client.create(hosting_path) |
439 | + except GitRepositoryCreationFault as e: |
440 | + # The hosting service failed. Log an OOPS for investigation. |
441 | + self._reportError(path, e, hosting_path=hosting_path) |
442 | + except Exception: |
443 | + # We don't want to keep the repository we created. |
444 | + transaction.abort() |
445 | + raise |
446 | + |
447 | + @return_fault |
448 | + def _translatePath(self, requester, path, permission, can_authenticate): |
449 | + if requester == LAUNCHPAD_ANONYMOUS: |
450 | + requester = None |
451 | + try: |
452 | + result = self._performLookup(path) |
453 | + if (result is None and requester is not None and |
454 | + permission == "write"): |
455 | + self._createRepository(requester, path) |
456 | + result = self._performLookup(path) |
457 | + if result is None: |
458 | + raise faults.PathTranslationError(path) |
459 | + if permission != "read" and not result["writable"]: |
460 | + raise faults.PermissionDenied() |
461 | + return result |
462 | + except faults.PermissionDenied: |
463 | + # Turn "permission denied" for anonymous HTTP requests into |
464 | + # "authorisation required", so that the user-agent has a chance |
465 | + # to try HTTP basic auth. |
466 | + if can_authenticate and requester is None: |
467 | + raise faults.Unauthorized() |
468 | + raise |
469 | + |
470 | + def translatePath(self, path, permission, requester_id, can_authenticate): |
471 | + """See `IGitAPI`.""" |
472 | + if requester_id is None: |
473 | + requester_id = LAUNCHPAD_ANONYMOUS |
474 | + return run_with_login( |
475 | + requester_id, self._translatePath, |
476 | + path.strip("/"), permission, can_authenticate) |
477 | |
478 | === added file 'lib/lp/code/xmlrpc/tests/test_git.py' |
479 | --- lib/lp/code/xmlrpc/tests/test_git.py 1970-01-01 00:00:00 +0000 |
480 | +++ lib/lp/code/xmlrpc/tests/test_git.py 2015-03-04 11:12:53 +0000 |
481 | @@ -0,0 +1,584 @@ |
482 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
483 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
484 | + |
485 | +"""Tests for the internal Git API.""" |
486 | + |
487 | +__metaclass__ = type |
488 | + |
489 | +from zope.component import getUtility |
490 | +from zope.security.proxy import removeSecurityProxy |
491 | + |
492 | +from lp.app.enums import InformationType |
493 | +from lp.code.errors import GitRepositoryCreationFault |
494 | +from lp.code.interfaces.codehosting import ( |
495 | + LAUNCHPAD_ANONYMOUS, |
496 | + LAUNCHPAD_SERVICES, |
497 | + ) |
498 | +from lp.code.interfaces.gitcollection import IAllGitRepositories |
499 | +from lp.code.interfaces.gitrepository import ( |
500 | + GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE, |
501 | + IGitRepositorySet, |
502 | + ) |
503 | +from lp.code.xmlrpc.git import GitAPI |
504 | +from lp.services.webapp.escaping import html_escape |
505 | +from lp.testing import ( |
506 | + ANONYMOUS, |
507 | + login, |
508 | + person_logged_in, |
509 | + TestCaseWithFactory, |
510 | + ) |
511 | +from lp.testing.layers import ( |
512 | + AppServerLayer, |
513 | + LaunchpadFunctionalLayer, |
514 | + ) |
515 | +from lp.xmlrpc import faults |
516 | + |
517 | + |
518 | +class FakeGitHostingClient: |
519 | + """A GitHostingClient lookalike that just logs calls.""" |
520 | + |
521 | + def __init__(self): |
522 | + self.calls = [] |
523 | + |
524 | + def create(self, path): |
525 | + self.calls.append(("create", path)) |
526 | + |
527 | + |
528 | +class BrokenGitHostingClient: |
529 | + """A GitHostingClient lookalike that pretends the remote end is down.""" |
530 | + |
531 | + def create(self, path): |
532 | + raise GitRepositoryCreationFault("nothing here") |
533 | + |
534 | + |
535 | +class TestGitAPIMixin: |
536 | + """Helper methods for `IGitAPI` tests, and security-relevant tests.""" |
537 | + |
538 | + def setUp(self): |
539 | + super(TestGitAPIMixin, self).setUp() |
540 | + self.git_api = GitAPI(None, None) |
541 | + self.git_api.hosting_client = FakeGitHostingClient() |
542 | + |
543 | + def assertPathTranslationError(self, requester, path, permission="read", |
544 | + can_authenticate=False): |
545 | + """Assert that the given path cannot be translated.""" |
546 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
547 | + requester = requester.id |
548 | + fault = self.git_api.translatePath( |
549 | + path, permission, requester, can_authenticate) |
550 | + self.assertEqual(faults.PathTranslationError(path.strip("/")), fault) |
551 | + |
552 | + def assertPermissionDenied(self, requester, path, |
553 | + message="Permission denied.", |
554 | + permission="read", can_authenticate=False): |
555 | + """Assert that looking at the given path returns PermissionDenied.""" |
556 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
557 | + requester = requester.id |
558 | + fault = self.git_api.translatePath( |
559 | + path, permission, requester, can_authenticate) |
560 | + self.assertEqual(faults.PermissionDenied(message), fault) |
561 | + |
562 | + def assertUnauthorized(self, requester, path, |
563 | + message="Authorisation required.", |
564 | + permission="read", can_authenticate=False): |
565 | + """Assert that looking at the given path returns Unauthorized.""" |
566 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
567 | + requester = requester.id |
568 | + fault = self.git_api.translatePath( |
569 | + path, permission, requester, can_authenticate) |
570 | + self.assertEqual(faults.Unauthorized(message), fault) |
571 | + |
572 | + def assertNotFound(self, requester, path, message, permission="read", |
573 | + can_authenticate=False): |
574 | + """Assert that looking at the given path returns NotFound.""" |
575 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
576 | + requester = requester.id |
577 | + fault = self.git_api.translatePath( |
578 | + path, permission, requester, can_authenticate) |
579 | + self.assertEqual(faults.NotFound(message), fault) |
580 | + |
581 | + def assertInvalidSourcePackageName(self, requester, path, name, |
582 | + permission="read", |
583 | + can_authenticate=False): |
584 | + """Assert that looking at the given path returns |
585 | + InvalidSourcePackageName.""" |
586 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
587 | + requester = requester.id |
588 | + fault = self.git_api.translatePath( |
589 | + path, permission, requester, can_authenticate) |
590 | + self.assertEqual(faults.InvalidSourcePackageName(name), fault) |
591 | + |
592 | + def assertInvalidBranchName(self, requester, path, message, |
593 | + permission="read", can_authenticate=False): |
594 | + """Assert that looking at the given path returns InvalidBranchName.""" |
595 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
596 | + requester = requester.id |
597 | + fault = self.git_api.translatePath( |
598 | + path, permission, requester, can_authenticate) |
599 | + self.assertEqual(faults.InvalidBranchName(Exception(message)), fault) |
600 | + |
601 | + def assertOopsOccurred(self, requester, path, |
602 | + permission="read", can_authenticate=False): |
603 | + """Assert that looking at the given path OOPSes.""" |
604 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
605 | + requester = requester.id |
606 | + fault = self.git_api.translatePath( |
607 | + path, permission, requester, can_authenticate) |
608 | + self.assertIsInstance(fault, faults.OopsOccurred) |
609 | + prefix = ( |
610 | + "An unexpected error has occurred while creating a Git " |
611 | + "repository. Please report a Launchpad bug and quote: ") |
612 | + self.assertStartsWith(fault.faultString, prefix) |
613 | + return fault.faultString[len(prefix):].rstrip(".") |
614 | + |
615 | + def assertTranslates(self, requester, path, repository, writable, |
616 | + permission="read", can_authenticate=False): |
617 | + if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
618 | + requester = requester.id |
619 | + translation = self.git_api.translatePath( |
620 | + path, permission, requester, can_authenticate) |
621 | + login(ANONYMOUS) |
622 | + self.assertEqual( |
623 | + {"path": repository.getInternalPath(), "writable": writable}, |
624 | + translation) |
625 | + |
626 | + def assertCreates(self, requester, path, can_authenticate=False): |
627 | + if requester in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES): |
628 | + requester_id = requester |
629 | + else: |
630 | + requester_id = requester.id |
631 | + translation = self.git_api.translatePath( |
632 | + path, "write", requester_id, can_authenticate) |
633 | + login(ANONYMOUS) |
634 | + repository = getUtility(IGitRepositorySet).getByPath( |
635 | + requester, path.lstrip("/")) |
636 | + self.assertIsNotNone(repository) |
637 | + self.assertEqual(requester, repository.registrant) |
638 | + self.assertEqual( |
639 | + {"path": repository.getInternalPath(), "writable": True}, |
640 | + translation) |
641 | + self.assertEqual( |
642 | + [("create", repository.getInternalPath())], |
643 | + self.git_api.hosting_client.calls) |
644 | + return repository |
645 | + |
646 | + def test_translatePath_private_repository(self): |
647 | + requester = self.factory.makePerson() |
648 | + repository = removeSecurityProxy( |
649 | + self.factory.makeGitRepository( |
650 | + owner=requester, information_type=InformationType.USERDATA)) |
651 | + path = u"/%s" % repository.unique_name |
652 | + self.assertTranslates(requester, path, repository, True) |
653 | + |
654 | + def test_translatePath_cannot_see_private_repository(self): |
655 | + requester = self.factory.makePerson() |
656 | + repository = removeSecurityProxy( |
657 | + self.factory.makeGitRepository( |
658 | + information_type=InformationType.USERDATA)) |
659 | + path = u"/%s" % repository.unique_name |
660 | + self.assertPermissionDenied(requester, path) |
661 | + |
662 | + def test_translatePath_anonymous_cannot_see_private_repository(self): |
663 | + repository = removeSecurityProxy( |
664 | + self.factory.makeGitRepository( |
665 | + information_type=InformationType.USERDATA)) |
666 | + path = u"/%s" % repository.unique_name |
667 | + self.assertPermissionDenied( |
668 | + LAUNCHPAD_ANONYMOUS, path, can_authenticate=False) |
669 | + self.assertUnauthorized( |
670 | + LAUNCHPAD_ANONYMOUS, path, can_authenticate=True) |
671 | + |
672 | + def test_translatePath_team_unowned(self): |
673 | + requester = self.factory.makePerson() |
674 | + team = self.factory.makeTeam(self.factory.makePerson()) |
675 | + repository = self.factory.makeGitRepository(owner=team) |
676 | + path = u"/%s" % repository.unique_name |
677 | + self.assertTranslates(requester, path, repository, False) |
678 | + self.assertPermissionDenied(requester, path, permission="write") |
679 | + |
680 | + def test_translatePath_create_personal_team_denied(self): |
681 | + # translatePath refuses to create a personal repository for a team |
682 | + # of which the requester is not a member. |
683 | + requester = self.factory.makePerson() |
684 | + team = self.factory.makeTeam() |
685 | + message = "%s is not a member of %s" % ( |
686 | + requester.displayname, team.displayname) |
687 | + self.assertPermissionDenied( |
688 | + requester, u"/~%s/+git/random" % team.name, message=message, |
689 | + permission="write") |
690 | + |
691 | + def test_translatePath_create_other_user(self): |
692 | + # Creating a repository for another user fails. |
693 | + requester = self.factory.makePerson() |
694 | + other_person = self.factory.makePerson() |
695 | + project = self.factory.makeProduct() |
696 | + name = self.factory.getUniqueString() |
697 | + path = u"/~%s/%s/+git/%s" % (other_person.name, project.name, name) |
698 | + message = "%s cannot create Git repositories owned by %s" % ( |
699 | + requester.displayname, other_person.displayname) |
700 | + self.assertPermissionDenied( |
701 | + requester, path, message=message, permission="write") |
702 | + |
703 | + def test_translatePath_create_project_not_owner(self): |
704 | + # Somebody without edit permission on the project cannot create a |
705 | + # repository and immediately set it as the default for that project. |
706 | + requester = self.factory.makePerson() |
707 | + project = self.factory.makeProduct() |
708 | + path = u"/%s" % project.name |
709 | + message = "You cannot set the default Git repository for '%s'." % ( |
710 | + path.strip("/")) |
711 | + initial_count = getUtility(IAllGitRepositories).count() |
712 | + self.assertPermissionDenied( |
713 | + requester, path, message=message, permission="write") |
714 | + # No repository was created. |
715 | + login(ANONYMOUS) |
716 | + self.assertEqual( |
717 | + initial_count, getUtility(IAllGitRepositories).count()) |
718 | + |
719 | + def test_translatePath_create_project_not_team_owner_default(self): |
720 | + # A non-owner member of a team cannot immediately set a |
721 | + # newly-created team-owned repository as that team's default for a |
722 | + # project. |
723 | + requester = self.factory.makePerson() |
724 | + team = self.factory.makeTeam(members=[requester]) |
725 | + project = self.factory.makeProduct() |
726 | + path = u"/~%s/%s" % (team.name, project.name) |
727 | + message = "You cannot set the default Git repository for '%s'." % ( |
728 | + path.strip("/")) |
729 | + initial_count = getUtility(IAllGitRepositories).count() |
730 | + self.assertPermissionDenied( |
731 | + requester, path, message=message, permission="write") |
732 | + # No repository was created. |
733 | + login(ANONYMOUS) |
734 | + self.assertEqual( |
735 | + initial_count, getUtility(IAllGitRepositories).count()) |
736 | + |
737 | + def test_translatePath_create_package_not_team_owner_default(self): |
738 | + # A non-owner member of a team cannot immediately set a |
739 | + # newly-created team-owned repository as that team's default for a |
740 | + # package. |
741 | + requester = self.factory.makePerson() |
742 | + team = self.factory.makeTeam(members=[requester]) |
743 | + dsp = self.factory.makeDistributionSourcePackage() |
744 | + path = u"/~%s/%s/+source/%s" % ( |
745 | + team.name, dsp.distribution.name, dsp.sourcepackagename.name) |
746 | + message = "You cannot set the default Git repository for '%s'." % ( |
747 | + path.strip("/")) |
748 | + initial_count = getUtility(IAllGitRepositories).count() |
749 | + self.assertPermissionDenied( |
750 | + requester, path, message=message, permission="write") |
751 | + # No repository was created. |
752 | + login(ANONYMOUS) |
753 | + self.assertEqual( |
754 | + initial_count, getUtility(IAllGitRepositories).count()) |
755 | + |
756 | + |
757 | +class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory): |
758 | + """Tests for the implementation of `IGitAPI`.""" |
759 | + |
760 | + layer = LaunchpadFunctionalLayer |
761 | + |
762 | + def test_translatePath_cannot_translate(self): |
763 | + # Sometimes translatePath will not know how to translate a path. |
764 | + # When this happens, it returns a Fault saying so, including the |
765 | + # path it couldn't translate. |
766 | + requester = self.factory.makePerson() |
767 | + self.assertPathTranslationError(requester, u"/untranslatable") |
768 | + |
769 | + def test_translatePath_repository(self): |
770 | + requester = self.factory.makePerson() |
771 | + repository = self.factory.makeGitRepository() |
772 | + path = u"/%s" % repository.unique_name |
773 | + self.assertTranslates(requester, path, repository, False) |
774 | + |
775 | + def test_translatePath_repository_with_no_leading_slash(self): |
776 | + requester = self.factory.makePerson() |
777 | + repository = self.factory.makeGitRepository() |
778 | + path = repository.unique_name |
779 | + self.assertTranslates(requester, path, repository, False) |
780 | + |
781 | + def test_translatePath_repository_with_trailing_slash(self): |
782 | + requester = self.factory.makePerson() |
783 | + repository = self.factory.makeGitRepository() |
784 | + path = u"/%s/" % repository.unique_name |
785 | + self.assertTranslates(requester, path, repository, False) |
786 | + |
787 | + def test_translatePath_repository_with_trailing_segments(self): |
788 | + requester = self.factory.makePerson() |
789 | + repository = self.factory.makeGitRepository() |
790 | + path = u"/%s/junk" % repository.unique_name |
791 | + self.assertPathTranslationError(requester, path) |
792 | + |
793 | + def test_translatePath_no_such_repository(self): |
794 | + requester = self.factory.makePerson() |
795 | + path = u"/%s/+git/no-such-repository" % requester.name |
796 | + self.assertPathTranslationError(requester, path) |
797 | + |
798 | + def test_translatePath_no_such_repository_non_ascii(self): |
799 | + requester = self.factory.makePerson() |
800 | + path = u"/%s/+git/\N{LATIN SMALL LETTER I WITH DIAERESIS}" % ( |
801 | + requester.name) |
802 | + self.assertPathTranslationError(requester, path) |
803 | + |
804 | + def test_translatePath_anonymous_public_repository(self): |
805 | + repository = self.factory.makeGitRepository() |
806 | + path = u"/%s" % repository.unique_name |
807 | + self.assertTranslates( |
808 | + LAUNCHPAD_ANONYMOUS, path, repository, False, |
809 | + can_authenticate=False) |
810 | + self.assertTranslates( |
811 | + LAUNCHPAD_ANONYMOUS, path, repository, False, |
812 | + can_authenticate=True) |
813 | + |
814 | + def test_translatePath_owned(self): |
815 | + requester = self.factory.makePerson() |
816 | + repository = self.factory.makeGitRepository(owner=requester) |
817 | + path = u"/%s" % repository.unique_name |
818 | + self.assertTranslates( |
819 | + requester, path, repository, True, permission="write") |
820 | + |
821 | + def test_translatePath_team_owned(self): |
822 | + requester = self.factory.makePerson() |
823 | + team = self.factory.makeTeam(requester) |
824 | + repository = self.factory.makeGitRepository(owner=team) |
825 | + path = u"/%s" % repository.unique_name |
826 | + self.assertTranslates( |
827 | + requester, path, repository, True, permission="write") |
828 | + |
829 | + def test_translatePath_shortened_path(self): |
830 | + # translatePath translates the shortened path to a repository. |
831 | + requester = self.factory.makePerson() |
832 | + repository = self.factory.makeGitRepository() |
833 | + with person_logged_in(repository.target.owner): |
834 | + getUtility(IGitRepositorySet).setDefaultRepository( |
835 | + repository.target, repository) |
836 | + path = u"/%s" % repository.target.name |
837 | + self.assertTranslates(requester, path, repository, False) |
838 | + |
839 | + def test_translatePath_create_project(self): |
840 | + # translatePath creates a project repository that doesn't exist, if |
841 | + # it can. |
842 | + requester = self.factory.makePerson() |
843 | + project = self.factory.makeProduct() |
844 | + self.assertCreates( |
845 | + requester, u"/~%s/%s/+git/random" % (requester.name, project.name)) |
846 | + |
847 | + def test_translatePath_create_package(self): |
848 | + # translatePath creates a package repository that doesn't exist, if |
849 | + # it can. |
850 | + requester = self.factory.makePerson() |
851 | + dsp = self.factory.makeDistributionSourcePackage() |
852 | + self.assertCreates( |
853 | + requester, |
854 | + u"/~%s/%s/+source/%s/+git/random" % ( |
855 | + requester.name, |
856 | + dsp.distribution.name, dsp.sourcepackagename.name)) |
857 | + |
858 | + def test_translatePath_create_personal(self): |
859 | + # translatePath creates a personal repository that doesn't exist, if |
860 | + # it can. |
861 | + requester = self.factory.makePerson() |
862 | + self.assertCreates(requester, u"/~%s/+git/random" % requester.name) |
863 | + |
864 | + def test_translatePath_create_personal_team(self): |
865 | + # translatePath creates a personal repository for a team of which |
866 | + # the requester is a member. |
867 | + requester = self.factory.makePerson() |
868 | + team = self.factory.makeTeam(members=[requester]) |
869 | + self.assertCreates(requester, u"/~%s/+git/random" % team.name) |
870 | + |
871 | + def test_translatePath_anonymous_cannot_create(self): |
872 | + # Anonymous users cannot create repositories. |
873 | + project = self.factory.makeProject() |
874 | + self.assertPathTranslationError( |
875 | + LAUNCHPAD_ANONYMOUS, u"/%s" % project.name, |
876 | + permission="write", can_authenticate=False) |
877 | + self.assertPathTranslationError( |
878 | + LAUNCHPAD_ANONYMOUS, u"/%s" % project.name, |
879 | + permission="write", can_authenticate=True) |
880 | + |
881 | + def test_translatePath_create_invalid_namespace(self): |
882 | + # Trying to create a repository at a path that isn't valid for Git |
883 | + # repositories returns a PermissionDenied fault. |
884 | + requester = self.factory.makePerson() |
885 | + path = u"/~%s" % requester.name |
886 | + message = "'%s' is not a valid Git repository path." % path.strip("/") |
887 | + self.assertPermissionDenied( |
888 | + requester, path, message=message, permission="write") |
889 | + |
890 | + def test_translatePath_create_no_such_person(self): |
891 | + # Creating a repository for a non-existent person fails. |
892 | + requester = self.factory.makePerson() |
893 | + self.assertNotFound( |
894 | + requester, u"/~nonexistent/+git/random", |
895 | + "User/team 'nonexistent' does not exist.", permission="write") |
896 | + |
897 | + def test_translatePath_create_no_such_project(self): |
898 | + # Creating a repository for a non-existent project fails. |
899 | + requester = self.factory.makePerson() |
900 | + self.assertNotFound( |
901 | + requester, u"/~%s/nonexistent/+git/random" % requester.name, |
902 | + "Project 'nonexistent' does not exist.", permission="write") |
903 | + |
904 | + def test_translatePath_create_no_such_person_or_project(self): |
905 | + # If neither the person nor the project are found, then the missing |
906 | + # person is reported in preference. |
907 | + requester = self.factory.makePerson() |
908 | + self.assertNotFound( |
909 | + requester, u"/~nonexistent/nonexistent/+git/random", |
910 | + "User/team 'nonexistent' does not exist.", permission="write") |
911 | + |
912 | + def test_translatePath_create_invalid_project(self): |
913 | + # Creating a repository with an invalid project name fails. |
914 | + requester = self.factory.makePerson() |
915 | + self.assertNotFound( |
916 | + requester, u"/_bad_project/+git/random", |
917 | + "Project '_bad_project' does not exist.", permission="write") |
918 | + |
919 | + def test_translatePath_create_missing_sourcepackagename(self): |
920 | + # If translatePath is asked to create a repository for a missing |
921 | + # source package, it will create the source package. |
922 | + requester = self.factory.makePerson() |
923 | + distro = self.factory.makeDistribution() |
924 | + repository_name = self.factory.getUniqueString() |
925 | + path = u"/~%s/%s/+source/new-package/+git/%s" % ( |
926 | + requester.name, distro.name, repository_name) |
927 | + repository = self.assertCreates(requester, path) |
928 | + self.assertEqual( |
929 | + "new-package", repository.target.sourcepackagename.name) |
930 | + |
931 | + def test_translatePath_create_invalid_sourcepackagename(self): |
932 | + # Creating a repository for an invalid source package name fails. |
933 | + requester = self.factory.makePerson() |
934 | + distro = self.factory.makeDistribution() |
935 | + repository_name = self.factory.getUniqueString() |
936 | + path = u"/~%s/%s/+source/new package/+git/%s" % ( |
937 | + requester.name, distro.name, repository_name) |
938 | + self.assertInvalidSourcePackageName( |
939 | + requester, path, "new package", permission="write") |
940 | + |
941 | + def test_translatePath_create_bad_name(self): |
942 | + # Creating a repository with an invalid name fails. |
943 | + requester = self.factory.makePerson() |
944 | + project = self.factory.makeProduct() |
945 | + invalid_name = "invalid name!" |
946 | + path = u"/~%s/%s/+git/%s" % ( |
947 | + requester.name, project.name, invalid_name) |
948 | + # LaunchpadValidationError unfortunately assumes its output is |
949 | + # always HTML, so it ends up double-escaped in XML-RPC faults. |
950 | + message = html_escape( |
951 | + "Invalid Git repository name '%s'. %s" % |
952 | + (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE)) |
953 | + self.assertInvalidBranchName( |
954 | + requester, path, message, permission="write") |
955 | + |
956 | + def test_translatePath_create_unicode_name(self): |
957 | + # Creating a repository with a non-ASCII invalid name fails. |
958 | + requester = self.factory.makePerson() |
959 | + project = self.factory.makeProduct() |
960 | + invalid_name = u"invalid\N{LATIN SMALL LETTER E WITH ACUTE}" |
961 | + path = u"/~%s/%s/+git/%s" % ( |
962 | + requester.name, project.name, invalid_name) |
963 | + # LaunchpadValidationError unfortunately assumes its output is |
964 | + # always HTML, so it ends up double-escaped in XML-RPC faults. |
965 | + message = html_escape( |
966 | + "Invalid Git repository name '%s'. %s" % |
967 | + (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE)) |
968 | + self.assertInvalidBranchName( |
969 | + requester, path, message, permission="write") |
970 | + |
971 | + def test_translatePath_create_project_default(self): |
972 | + # A repository can be created and immediately set as the default for |
973 | + # a project. |
974 | + requester = self.factory.makePerson() |
975 | + project = self.factory.makeProduct(owner=requester) |
976 | + repository = self.assertCreates(requester, u"/%s" % project.name) |
977 | + self.assertTrue(repository.target_default) |
978 | + self.assertFalse(repository.owner_default) |
979 | + |
980 | + def test_translatePath_create_package_default_denied(self): |
981 | + # A repository cannot (yet) be created and immediately set as the |
982 | + # default for a package. |
983 | + requester = self.factory.makePerson() |
984 | + dsp = self.factory.makeDistributionSourcePackage() |
985 | + path = u"/%s/+source/%s" % ( |
986 | + dsp.distribution.name, dsp.sourcepackagename.name) |
987 | + message = ( |
988 | + "Cannot automatically set the default repository for this target; " |
989 | + "push to a named repository instead.") |
990 | + self.assertPermissionDenied( |
991 | + requester, path, message=message, permission="write") |
992 | + |
993 | + def test_translatePath_create_project_owner_default(self): |
994 | + # A repository can be created and immediately set as its owner's |
995 | + # default for a project. |
996 | + requester = self.factory.makePerson() |
997 | + project = self.factory.makeProduct() |
998 | + repository = self.assertCreates( |
999 | + requester, u"/~%s/%s" % (requester.name, project.name)) |
1000 | + self.assertFalse(repository.target_default) |
1001 | + self.assertTrue(repository.owner_default) |
1002 | + |
1003 | + def test_translatePath_create_project_team_owner_default(self): |
1004 | + # The owner of a team can create a team-owned repository and |
1005 | + # immediately set it as that team's default for a project. |
1006 | + requester = self.factory.makePerson() |
1007 | + team = self.factory.makeTeam(owner=requester) |
1008 | + project = self.factory.makeProduct() |
1009 | + repository = self.assertCreates( |
1010 | + requester, u"/~%s/%s" % (team.name, project.name)) |
1011 | + self.assertFalse(repository.target_default) |
1012 | + self.assertTrue(repository.owner_default) |
1013 | + |
1014 | + def test_translatePath_create_package_owner_default(self): |
1015 | + # A repository can be created and immediately set as its owner's |
1016 | + # default for a package. |
1017 | + requester = self.factory.makePerson() |
1018 | + dsp = self.factory.makeDistributionSourcePackage() |
1019 | + path = u"/~%s/%s/+source/%s" % ( |
1020 | + requester.name, dsp.distribution.name, dsp.sourcepackagename.name) |
1021 | + repository = self.assertCreates(requester, path) |
1022 | + self.assertFalse(repository.target_default) |
1023 | + self.assertTrue(repository.owner_default) |
1024 | + |
1025 | + def test_translatePath_create_package_team_owner_default(self): |
1026 | + # The owner of a team can create a team-owned repository and |
1027 | + # immediately set it as that team's default for a package. |
1028 | + requester = self.factory.makePerson() |
1029 | + team = self.factory.makeTeam(owner=requester) |
1030 | + dsp = self.factory.makeDistributionSourcePackage() |
1031 | + path = u"/~%s/%s/+source/%s" % ( |
1032 | + team.name, dsp.distribution.name, dsp.sourcepackagename.name) |
1033 | + repository = self.assertCreates(requester, path) |
1034 | + self.assertFalse(repository.target_default) |
1035 | + self.assertTrue(repository.owner_default) |
1036 | + |
1037 | + def test_translatePath_create_broken_hosting_service(self): |
1038 | + # If the hosting service is down, trying to create a repository |
1039 | + # fails and doesn't leave junk around in the Launchpad database. |
1040 | + self.git_api.hosting_client = BrokenGitHostingClient() |
1041 | + requester = self.factory.makePerson() |
1042 | + initial_count = getUtility(IAllGitRepositories).count() |
1043 | + oops_id = self.assertOopsOccurred( |
1044 | + requester, u"/~%s/+git/random" % requester.name, |
1045 | + permission="write") |
1046 | + login(ANONYMOUS) |
1047 | + self.assertEqual( |
1048 | + initial_count, getUtility(IAllGitRepositories).count()) |
1049 | + # The error report OOPS ID should match the fault, and the traceback |
1050 | + # text should show the underlying exception. |
1051 | + self.assertEqual(1, len(self.oopses)) |
1052 | + self.assertEqual(oops_id, self.oopses[0]["id"]) |
1053 | + self.assertIn( |
1054 | + "GitRepositoryCreationFault: nothing here", |
1055 | + self.oopses[0]["tb_text"]) |
1056 | + |
1057 | + |
1058 | +class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory): |
1059 | + """Slow tests for `IGitAPI`. |
1060 | + |
1061 | + These use AppServerLayer to check that `run_with_login` is behaving |
1062 | + itself properly. |
1063 | + """ |
1064 | + |
1065 | + layer = AppServerLayer |
1066 | |
1067 | === modified file 'lib/lp/systemhomes.py' |
1068 | --- lib/lp/systemhomes.py 2013-06-20 05:50:00 +0000 |
1069 | +++ lib/lp/systemhomes.py 2015-03-04 11:12:53 +0000 |
1070 | @@ -1,4 +1,4 @@ |
1071 | -# Copyright 2009-2012 Canonical Ltd. This software is licensed under the |
1072 | +# Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
1073 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1074 | |
1075 | """Content classes for the 'home pages' of the subsystems of Launchpad.""" |
1076 | @@ -47,6 +47,7 @@ |
1077 | from lp.code.interfaces.codeimportscheduler import ( |
1078 | ICodeImportSchedulerApplication, |
1079 | ) |
1080 | +from lp.code.interfaces.gitapi import IGitApplication |
1081 | from lp.hardwaredb.interfaces.hwdb import ( |
1082 | IHWDBApplication, |
1083 | IHWDeviceSet, |
1084 | @@ -92,6 +93,12 @@ |
1085 | title = "Code Import Scheduler" |
1086 | |
1087 | |
1088 | +class GitApplication: |
1089 | + implements(IGitApplication) |
1090 | + |
1091 | + title = "Git API" |
1092 | + |
1093 | + |
1094 | class PrivateMaloneApplication: |
1095 | """ExternalBugTracker authentication token end-point.""" |
1096 | implements(IPrivateMaloneApplication) |
1097 | |
1098 | === modified file 'lib/lp/xmlrpc/application.py' |
1099 | --- lib/lp/xmlrpc/application.py 2013-01-07 02:40:55 +0000 |
1100 | +++ lib/lp/xmlrpc/application.py 2015-03-04 11:12:53 +0000 |
1101 | @@ -1,4 +1,4 @@ |
1102 | -# Copyright 2009-2011 Canonical Ltd. This software is licensed under the |
1103 | +# Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
1104 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1105 | |
1106 | """XML-RPC API to the application roots.""" |
1107 | @@ -24,6 +24,7 @@ |
1108 | from lp.code.interfaces.codeimportscheduler import ( |
1109 | ICodeImportSchedulerApplication, |
1110 | ) |
1111 | +from lp.code.interfaces.gitapi import IGitApplication |
1112 | from lp.registry.interfaces.mailinglist import IMailingListApplication |
1113 | from lp.registry.interfaces.person import ( |
1114 | ICanonicalSSOApplication, |
1115 | @@ -80,6 +81,11 @@ |
1116 | """See `IPrivateApplication`.""" |
1117 | return getUtility(IFeatureFlagApplication) |
1118 | |
1119 | + @property |
1120 | + def git(self): |
1121 | + """See `IPrivateApplication`.""" |
1122 | + return getUtility(IGitApplication) |
1123 | + |
1124 | |
1125 | class ISelfTest(Interface): |
1126 | """XMLRPC external interface for testing the XMLRPC external interface.""" |
1127 | |
1128 | === modified file 'lib/lp/xmlrpc/configure.zcml' |
1129 | --- lib/lp/xmlrpc/configure.zcml 2012-10-31 14:29:13 +0000 |
1130 | +++ lib/lp/xmlrpc/configure.zcml 2015-03-04 11:12:53 +0000 |
1131 | @@ -1,4 +1,4 @@ |
1132 | -<!-- Copyright 2009-2010 Canonical Ltd. This software is licensed under the |
1133 | +<!-- Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
1134 | GNU Affero General Public License version 3 (see the file LICENSE). |
1135 | --> |
1136 | |
1137 | @@ -48,6 +48,19 @@ |
1138 | /> |
1139 | |
1140 | <securedutility |
1141 | + class="lp.systemhomes.GitApplication" |
1142 | + provides="lp.code.interfaces.gitapi.IGitApplication"> |
1143 | + <allow interface="lp.code.interfaces.gitapi.IGitApplication"/> |
1144 | + </securedutility> |
1145 | + |
1146 | + <xmlrpc:view |
1147 | + for="lp.code.interfaces.gitapi.IGitApplication" |
1148 | + interface="lp.code.interfaces.gitapi.IGitAPI" |
1149 | + class="lp.code.xmlrpc.git.GitAPI" |
1150 | + permission="zope.Public" |
1151 | + /> |
1152 | + |
1153 | + <securedutility |
1154 | class="lp.systemhomes.PrivateMaloneApplication" |
1155 | provides="lp.bugs.interfaces.malone.IPrivateMaloneApplication"> |
1156 | <allow interface="lp.bugs.interfaces.malone.IPrivateMaloneApplication"/> |
1157 | @@ -207,4 +220,8 @@ |
1158 | <class class="lp.xmlrpc.faults.InvalidSourcePackageName"> |
1159 | <require like_class="xmlrpclib.Fault" /> |
1160 | </class> |
1161 | + |
1162 | + <class class="lp.xmlrpc.faults.Unauthorized"> |
1163 | + <require like_class="xmlrpclib.Fault" /> |
1164 | + </class> |
1165 | </configure> |
1166 | |
1167 | === modified file 'lib/lp/xmlrpc/faults.py' |
1168 | --- lib/lp/xmlrpc/faults.py 2012-10-31 19:13:34 +0000 |
1169 | +++ lib/lp/xmlrpc/faults.py 2015-03-04 11:12:53 +0000 |
1170 | @@ -9,6 +9,7 @@ |
1171 | __metaclass__ = type |
1172 | |
1173 | __all__ = [ |
1174 | + 'AccountSuspended', |
1175 | 'BadStatus', |
1176 | 'BranchAlreadyRegistered', |
1177 | 'BranchCreationForbidden', |
1178 | @@ -20,8 +21,9 @@ |
1179 | 'InvalidBranchIdentifier', |
1180 | 'InvalidBranchName', |
1181 | 'InvalidBranchUniqueName', |
1182 | + 'InvalidBranchUrl', |
1183 | + 'InvalidPath', |
1184 | 'InvalidProductName', |
1185 | - 'InvalidBranchUrl', |
1186 | 'InvalidSourcePackageName', |
1187 | 'OopsOccurred', |
1188 | 'NoBranchWithID', |
1189 | @@ -30,16 +32,22 @@ |
1190 | 'NoSuchBug', |
1191 | 'NoSuchCodeImportJob', |
1192 | 'NoSuchDistribution', |
1193 | + 'NoSuchDistroSeries', |
1194 | 'NoSuchPackage', |
1195 | 'NoSuchPerson', |
1196 | 'NoSuchPersonWithName', |
1197 | 'NoSuchProduct', |
1198 | 'NoSuchProductSeries', |
1199 | + 'NoSuchSourcePackageName', |
1200 | 'NoSuchTeamMailingList', |
1201 | + 'NotFound', |
1202 | 'NotInTeam', |
1203 | 'NoUrlForBranch', |
1204 | + 'PathTranslationError', |
1205 | + 'PermissionDenied', |
1206 | 'RequiredParameterMissing', |
1207 | 'TeamEmailAddress', |
1208 | + 'Unauthorized', |
1209 | 'UnexpectedStatusReport', |
1210 | ] |
1211 | |
1212 | @@ -502,3 +510,15 @@ |
1213 | def __init__(self, email, openid_identifier): |
1214 | LaunchpadFault.__init__( |
1215 | self, email=email, openid_identifier=openid_identifier) |
1216 | + |
1217 | + |
1218 | +# American English spelling to line up with httplib etc. |
1219 | +class Unauthorized(LaunchpadFault): |
1220 | + """Permission was denied, but authorisation may help.""" |
1221 | + |
1222 | + error_code = 410 |
1223 | + msg_template = ( |
1224 | + "%(message)s") |
1225 | + |
1226 | + def __init__(self, message="Authorisation required."): |
1227 | + LaunchpadFault.__init__(self, message=message) |
1228 | |
1229 | === modified file 'lib/lp/xmlrpc/interfaces.py' |
1230 | --- lib/lp/xmlrpc/interfaces.py 2012-01-15 21:06:58 +0000 |
1231 | +++ lib/lp/xmlrpc/interfaces.py 2015-03-04 11:12:53 +0000 |
1232 | @@ -1,4 +1,4 @@ |
1233 | -# Copyright 2011 Canonical Ltd. This software is licensed under the |
1234 | +# Copyright 2011-2015 Canonical Ltd. This software is licensed under the |
1235 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1236 | |
1237 | """Interfaces for the Launchpad application.""" |
1238 | @@ -34,3 +34,5 @@ |
1239 | """Canonical SSO XML-RPC end point.""") |
1240 | |
1241 | featureflags = Attribute("""Feature flag information endpoint""") |
1242 | + |
1243 | + git = Attribute("Git end point.") |
1244 | |
1245 | === modified file 'setup.py' |
1246 | --- setup.py 2015-01-06 12:47:59 +0000 |
1247 | +++ setup.py 2015-03-04 11:12:53 +0000 |
1248 | @@ -79,6 +79,7 @@ |
1249 | 'python-openid', |
1250 | 'pytz', |
1251 | 'rabbitfixture', |
1252 | + 'requests', |
1253 | 's4', |
1254 | 'setproctitle', |
1255 | 'setuptools', |
I think I've fixed all this now, aside from one of your comments to which I've replied inline.