Merge ~ilasc/launchpad:add-mp-url-to-git-push into launchpad:master

Proposed by Ioana Lasc
Status: Merged
Approved by: Ioana Lasc
Approved revision: 9d74d487fa58d8c3cea4d78768aad92bc2ffd2f6
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~ilasc/launchpad:add-mp-url-to-git-push
Merge into: launchpad:master
Diff against target: 386 lines (+308/-2)
3 files modified
lib/lp/code/interfaces/gitapi.py (+13/-0)
lib/lp/code/xmlrpc/git.py (+46/-1)
lib/lp/code/xmlrpc/tests/test_git.py (+249/-1)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+377044@code.launchpad.net

Commit message

Add MP URL to git push

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

This needs Unit Tests and imports re-arranging - the rest is definitely in need a of a critical eye.

Revision history for this message
Colin Watson (cjwatson) :
d9bdd8f... by Ioana Lasc

Addressed review comments and added unit tests.

Revision history for this message
Ioana Lasc (ilasc) wrote :

Addressed code review comments and added a couple of Unit Tests.

Still working on moving the check for the default branch to the turnip side.

f76a6d6... by Ioana Lasc

Move check for default branch from LP to turnip

Revision history for this message
Ioana Lasc (ilasc) wrote :

Moved the check for default branch to Turnip per our conversation.

Goes hand in hand with this on the Turnip side: https://code.launchpad.net/~ilasc/turnip/+git/turnip/+merge/377045

Both now ready for review.

Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
d36f50c... by Ioana Lasc

Add authentication for getMergeProposalURL

Split up the implementation into `getMergeProposalURL` and
`_getMergeProposalURL` to add authetication for the
getMergeProposalURL endpoint on the Git API. As LP returns
data back to Turnip this needs to be an authenticated
operation. Added unit tests for several scenarios.
Needs more comments and more tests for other scenarios.

c0ec754... by Ioana Lasc

Add unit tests for getMergeProposalURL.

After changing to an authenticated getMergeProposalURL
more unit tests were required to cover the authenticated
user and code imports scenarios.

Revision history for this message
Ioana Lasc (ilasc) wrote :

Added more Unit Tests and addressed almost all comments apart from

from six.moves.urllib.parse import quote instead of just importing urllib

can't get the code to compile with it - which is a known PyCharm issue, tried running cli tests but code doesn't run in cli either and make lint fails.

I believe there is also an issue with Unauthorized scenario in getMergeProposalURL. I went by below comment and instead of raising Unauthorized I'm returning '', but now I'm not sure if that's not meant for timeouts only?

Comment I'm referring to in git.py line 564:
            # XXX cjwatson 2019-05-09: It would be simpler to just raise
            # this directly, but turnip won't handle it very gracefully at
            # the moment. It's possible to reach this by being very unlucky
            # about the timing of a push.

Revision history for this message
Colin Watson (cjwatson) wrote :

Not being able to use six.moves.urllib.parse is a serious problem; I expect to do a bulk conversion of all of Launchpad to that form soon. What exactly was the compilation failure, and can you give a link to the known PyCharm issue?

review: Needs Fixing
6f934f1... by Ioana Lasc

Refactor Unit Tests for GIT API getMergeProposalURL

Revision history for this message
Ioana Lasc (ilasc) wrote :

Thank you Colin. MP is now ready for review in tandem with: https://code.launchpad.net/~ilasc/turnip/+git/turnip/+merge/377045

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
678cfce... by Ioana Lasc

Address code review comments

Added minor changes to unit tests suggested in code review
for the add-mp-url-to-git-push branch.

Revision history for this message
Ioana Lasc (ilasc) wrote :

Thanks Colin, last round of updates done and noted that we don't land 377045 until this makes it to Prod.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
9d74d48... by Ioana Lasc

Remove commented out code

Removed commented code left in lp/code/xmlrpc/tests/test_git.py.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/code/interfaces/gitapi.py b/lib/lp/code/interfaces/gitapi.py
index c7db491..c289dee 100644
--- a/lib/lp/code/interfaces/gitapi.py
+++ b/lib/lp/code/interfaces/gitapi.py
@@ -79,3 +79,16 @@ class IGitAPI(Interface):
7979
80 :returns: A list of rules for the user in the specified repository80 :returns: A list of rules for the user in the specified repository
81 """81 """
82
83 def getMergeProposalURL(translated_path, branch, auth_params):
84 """Return the URL for a merge proposal.
85
86 When a `branch` that is not the default branch in a repository
87 is pushed, the URL where the merge proposal for that branch can
88 be opened will be generated and returned.
89
90 :returns: The URL to register a merge proposal for the branch in the
91 specified repository. A `GitRepositoryNotFound` fault is returned
92 if no repository can be found for 'translated_path',
93 or an `Unauthorized` fault for unauthorized push attempts.
94 """
diff --git a/lib/lp/code/xmlrpc/git.py b/lib/lp/code/xmlrpc/git.py
index cbf9205..b34307a 100644
--- a/lib/lp/code/xmlrpc/git.py
+++ b/lib/lp/code/xmlrpc/git.py
@@ -15,6 +15,7 @@ import uuid
15from pymacaroons import Macaroon15from pymacaroons import Macaroon
16import six16import six
17from six.moves import xmlrpc_client17from six.moves import xmlrpc_client
18from six.moves.urllib.parse import quote
18import transaction19import transaction
19from zope.component import (20from zope.component import (
20 ComponentLookupError,21 ComponentLookupError,
@@ -72,7 +73,10 @@ from lp.services.macaroons.interfaces import (
72 IMacaroonIssuer,73 IMacaroonIssuer,
73 NO_USER,74 NO_USER,
74 )75 )
75from lp.services.webapp import LaunchpadXMLRPCView76from lp.services.webapp import (
77 canonical_url,
78 LaunchpadXMLRPCView,
79 )
76from lp.services.webapp.authorization import check_permission80from lp.services.webapp.authorization import check_permission
77from lp.services.webapp.errorlog import ScriptRequest81from lp.services.webapp.errorlog import ScriptRequest
78from lp.xmlrpc import faults82from lp.xmlrpc import faults
@@ -427,6 +431,47 @@ class GitAPI(LaunchpadXMLRPCView):
427 logger.info("notify succeeded")431 logger.info("notify succeeded")
428432
429 @return_fault433 @return_fault
434 def _getMergeProposalURL(self, requester, translated_path, branch,
435 auth_params):
436 if requester == LAUNCHPAD_ANONYMOUS:
437 requester = None
438 repository = removeSecurityProxy(
439 getUtility(IGitLookup).getByHostingPath(translated_path))
440 if repository is None:
441 raise faults.GitRepositoryNotFound(translated_path)
442
443 verified = self._verifyAuthParams(requester, repository, auth_params)
444 if verified is not None and verified.user is NO_USER:
445 # Showing a merge proposal URL may be useful to ordinary users,
446 # but it doesn't make sense in the context of an internal service.
447 return None
448
449 # We assemble the URL this way here because the ref may not exist yet.
450 base_url = canonical_url(repository, rootsite='code')
451 mp_url = "%s/+ref/%s/+register-merge" % (
452 base_url, quote(branch))
453 return mp_url
454
455 def getMergeProposalURL(self, translated_path, branch, auth_params):
456 """See `IGitAPI`."""
457 logger = self._getLogger(auth_params.get("request-id"))
458 requester_id = _get_requester_id(auth_params)
459 logger.info(
460 "Request received: getMergeProposalURL('%s, %s') for %s",
461 translated_path, branch, requester_id)
462 result = run_with_login(
463 requester_id, self._getMergeProposalURL,
464 translated_path, branch, auth_params)
465 if isinstance(result, xmlrpc_client.Fault):
466 logger.error("getMergeProposalURL failed: %r", result)
467 else:
468 # The result of getMergeProposalURL is not sensitive for logging
469 # purposes (it may refer to private artifacts, but contains no
470 # credentials, only the merge proposal URL).
471 logger.info("getMergeProposalURL succeeded: %s" % result)
472 return result
473
474 @return_fault
430 def _authenticateWithPassword(self, username, password):475 def _authenticateWithPassword(self, username, password):
431 """Authenticate a user by username and password.476 """Authenticate a user by username and password.
432477
diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
index df65f0e..6f12f1f 100644
--- a/lib/lp/code/xmlrpc/tests/test_git.py
+++ b/lib/lp/code/xmlrpc/tests/test_git.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for the internal Git API."""4"""Tests for the internal Git API."""
@@ -11,6 +11,7 @@ from fixtures import FakeLogger
11from pymacaroons import Macaroon11from pymacaroons import Macaroon
12import six12import six
13from six.moves import xmlrpc_client13from six.moves import xmlrpc_client
14from six.moves.urllib.parse import quote
14from testtools.matchers import (15from testtools.matchers import (
15 Equals,16 Equals,
16 IsInstance,17 IsInstance,
@@ -51,6 +52,7 @@ from lp.services.macaroons.interfaces import (
51 NO_USER,52 NO_USER,
52 )53 )
53from lp.services.macaroons.model import MacaroonIssuerBase54from lp.services.macaroons.model import MacaroonIssuerBase
55from lp.services.webapp import canonical_url
54from lp.services.webapp.escaping import html_escape56from lp.services.webapp.escaping import html_escape
55from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS57from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
56from lp.testing import (58from lp.testing import (
@@ -326,6 +328,15 @@ class TestGitAPIMixin:
326 ])328 ])
327 for ref_path, ref_permissions in permissions.items())))329 for ref_path, ref_permissions in permissions.items())))
328330
331 def assertHasMergeProposalURL(self, repository,
332 pushed_branch, auth_params):
333 base_url = canonical_url(repository, rootsite='code')
334 expected_mp_url = "%s/+ref/%s/+register-merge" % (
335 base_url, quote(pushed_branch))
336 result = self.git_api.getMergeProposalURL(
337 repository.getInternalPath(), pushed_branch, auth_params)
338 self.assertEqual(expected_mp_url, result)
339
329 def test_translatePath_private_repository(self):340 def test_translatePath_private_repository(self):
330 requester = self.factory.makePerson()341 requester = self.factory.makePerson()
331 repository = removeSecurityProxy(342 repository = removeSecurityProxy(
@@ -636,6 +647,13 @@ class TestGitAPIMixin:
636 faults.GitRepositoryNotFound("nonexistent"), None,647 faults.GitRepositoryNotFound("nonexistent"), None,
637 "checkRefPermissions", "nonexistent", [], {"uid": requester.id})648 "checkRefPermissions", "nonexistent", [], {"uid": requester.id})
638649
650 def test_getMergeProposalURL__nonexistent_repository(self):
651 requester = self.factory.makePerson()
652 self.assertFault(
653 faults.GitRepositoryNotFound("nonexistent"), None,
654 "getMergeProposalURL", "nonexistent", 'branch1',
655 {"uid": requester.id})
656
639657
640class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):658class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
641 """Tests for the implementation of `IGitAPI`."""659 """Tests for the implementation of `IGitAPI`."""
@@ -1265,6 +1283,236 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
1265 requester, path, permission="read",1283 requester, path, permission="read",
1266 macaroon_raw=macaroon.serialize())1284 macaroon_raw=macaroon.serialize())
12671285
1286 def test_getMergeProposalURL_user(self):
1287 # A merge proposal URL is returned by LP for a non-default branch
1288 # pushed by a user that has their ordinary privileges on the
1289 # corresponding repository.
1290 requester_owner = self.factory.makePerson()
1291 repository = self.factory.makeGitRepository(owner=requester_owner)
1292 self.factory.makeGitRefs(
1293 repository=repository, paths=[u'refs/heads/master'])
1294 removeSecurityProxy(repository).default_branch = u'refs/heads/master'
1295 pushed_branch = 'branch1'
1296 self.assertHasMergeProposalURL(repository, pushed_branch,
1297 {"uid": requester_owner.id})
1298
1299 # Turnip verifies if the pushed branch is the default branch
1300 # in a repository and calls LP only for non default branches.
1301 # Consequently LP will process any incoming branch from Turnip
1302 # as being non default and produce a merge proposal URL for it.
1303 self.factory.makeGitRefs(
1304 repository=repository, paths=[u'refs/heads/%s' % pushed_branch])
1305 removeSecurityProxy(repository).default_branch = (
1306 u'refs/heads/%s' % pushed_branch)
1307 self.assertHasMergeProposalURL(repository, pushed_branch,
1308 {"uid": requester_owner.id})
1309
1310 requester_non_owner = self.factory.makePerson()
1311 self.assertHasMergeProposalURL(repository, pushed_branch,
1312 {"uid": requester_non_owner.id})
1313
1314 def test_getMergeProposalURL_user_macaroon(self):
1315 # The merge proposal URL is returned by LP for a non-default branch
1316 # pushed by a user with a suitable macaroon that
1317 # has their ordinary privileges on the corresponding repository.
1318
1319 self.pushConfig("codehosting", git_macaroon_secret_key="some-secret")
1320 requester = self.factory.makePerson()
1321 repository = self.factory.makeGitRepository(owner=requester)
1322 issuer = getUtility(IMacaroonIssuer, "git-repository")
1323 self.factory.makeGitRefs(
1324 repository=repository, paths=[u'refs/heads/master'])
1325 removeSecurityProxy(repository).default_branch = u'refs/heads/master'
1326
1327 pushed_branch = 'branch1'
1328 with person_logged_in(requester):
1329 macaroon = removeSecurityProxy(issuer).issueMacaroon(
1330 repository, user=requester)
1331 auth_params = _make_auth_params(requester,
1332 macaroon_raw=macaroon.serialize())
1333 self.assertHasMergeProposalURL(repository, pushed_branch, auth_params)
1334
1335 def test_getMergeProposalURL_user_mismatch(self):
1336 # getMergeProposalURL refuses macaroons in the case where the
1337 # user doesn't match what the issuer claims was verified. (We use a
1338 # dummy issuer for this, since this is a stopgap check to defend
1339 # against issuer bugs)
1340
1341 issuer = DummyMacaroonIssuer()
1342 # Claim to be the code-import-job issuer. This is a bit weird, but
1343 # it gets us past the difficulty that only certain named issuers are
1344 # allowed to confer write permissions.
1345 issuer.identifier = "code-import-job"
1346 self.useFixture(ZopeUtilityFixture(
1347 issuer, IMacaroonIssuer, name="code-import-job"))
1348 requesters = [self.factory.makePerson() for _ in range(2)]
1349 owner = self.factory.makeTeam(members=requesters)
1350 repository = self.factory.makeGitRepository(owner=owner)
1351 self.factory.makeGitRefs(
1352 repository=repository, paths=[u'refs/heads/master'])
1353 removeSecurityProxy(repository).default_branch = u'refs/heads/master'
1354 pushed_branch = 'branch1'
1355 macaroon = issuer.issueMacaroon(repository)
1356
1357 for verified_user, authorized, unauthorized in (
1358 (requesters[0], [requesters[0]],
1359 [LAUNCHPAD_SERVICES, requesters[1], None]),
1360 ([None, NO_USER], [],
1361 [LAUNCHPAD_SERVICES] + requesters + [None]),
1362 ):
1363 issuer._verified_user = verified_user
1364 for requester in authorized:
1365 login(ANONYMOUS)
1366 auth_params = _make_auth_params(
1367 requester, macaroon_raw=macaroon.serialize())
1368 self.assertHasMergeProposalURL(repository,
1369 pushed_branch, auth_params)
1370 for requester in unauthorized:
1371 login(ANONYMOUS)
1372 auth_params = _make_auth_params(
1373 requester, macaroon_raw=macaroon.serialize())
1374
1375 self.assertFault(
1376 faults.Unauthorized, None,
1377 "getMergeProposalURL", repository.getInternalPath(),
1378 pushed_branch, auth_params)
1379
1380 def test_getMergeProposalURL_code_import(self):
1381 # A merge proposal URL from LP to Turnip is not returned for
1382 # code import job as there is no User at the other end.
1383
1384 issuer = DummyMacaroonIssuer()
1385 # Claim to be the code-import-job issuer. This is a bit weird, but
1386 # it gets us past the difficulty that only certain named issuers are
1387 # allowed to confer write permissions.
1388 self.pushConfig(
1389 "launchpad", internal_macaroon_secret_key="some-secret")
1390 machine = self.factory.makeCodeImportMachine(set_online=True)
1391 code_imports = [
1392 self.factory.makeCodeImport(
1393 target_rcs_type=TargetRevisionControlSystems.GIT)
1394 for _ in range(2)]
1395 with celebrity_logged_in("vcs_imports"):
1396 jobs = [
1397 self.factory.makeCodeImportJob(code_import=code_import)
1398 for code_import in code_imports]
1399 issuer = getUtility(IMacaroonIssuer, "code-import-job")
1400 macaroons = [
1401 removeSecurityProxy(issuer).issueMacaroon(job) for job in jobs]
1402 repository = code_imports[0].git_repository
1403 auth_params = _make_auth_params(
1404 LAUNCHPAD_SERVICES, macaroons[0].serialize())
1405 pushed_branch = 'branch1'
1406 self.assertFault(
1407 faults.Unauthorized, None,
1408 "getMergeProposalURL", repository.getInternalPath(),
1409 pushed_branch, auth_params)
1410
1411 with celebrity_logged_in("vcs_imports"):
1412 getUtility(ICodeImportJobWorkflow).startJob(jobs[0], machine)
1413 auth_params = _make_auth_params(
1414 LAUNCHPAD_SERVICES, macaroons[0].serialize())
1415
1416 self.assertFault(
1417 faults.Unauthorized, None,
1418 "getMergeProposalURL",
1419 removeSecurityProxy(repository).getInternalPath(),
1420 pushed_branch, auth_params)
1421
1422 auth_params = _make_auth_params(
1423 LAUNCHPAD_SERVICES, macaroons[1].serialize())
1424
1425 self.assertFault(
1426 faults.Unauthorized, None,
1427 "getMergeProposalURL",
1428 removeSecurityProxy(repository).getInternalPath(),
1429 pushed_branch, auth_params)
1430
1431 auth_params = _make_auth_params(
1432 LAUNCHPAD_SERVICES,
1433 macaroon_raw=Macaroon(
1434 location=config.vhost.mainsite.hostname, identifier="another",
1435 key="another-secret").serialize())
1436 self.assertFault(
1437 faults.Unauthorized, None,
1438 "getMergeProposalURL",
1439 removeSecurityProxy(repository).getInternalPath(),
1440 pushed_branch, auth_params)
1441
1442 auth_params = _make_auth_params(
1443 LAUNCHPAD_SERVICES, macaroon_raw="nonsense")
1444
1445 self.assertFault(
1446 faults.Unauthorized, None,
1447 "getMergeProposalURL",
1448 removeSecurityProxy(repository).getInternalPath(),
1449 pushed_branch, auth_params)
1450
1451 def test_getMergeProposalURL_private_code_import(self):
1452 # We do not send the Merge Proposal URL back
1453 # to a Code Import Job.
1454
1455 self.pushConfig(
1456 "launchpad", internal_macaroon_secret_key="some-secret")
1457 machine = self.factory.makeCodeImportMachine(set_online=True)
1458 code_imports = [
1459 self.factory.makeCodeImport(
1460 target_rcs_type=TargetRevisionControlSystems.GIT)
1461 for _ in range(2)]
1462 private_repository = code_imports[0].git_repository
1463 removeSecurityProxy(private_repository).transitionToInformationType(
1464 InformationType.PRIVATESECURITY, private_repository.owner)
1465 with celebrity_logged_in("vcs_imports"):
1466 jobs = [
1467 self.factory.makeCodeImportJob(code_import=code_import)
1468 for code_import in code_imports]
1469 issuer = getUtility(IMacaroonIssuer, "code-import-job")
1470 macaroons = [
1471 removeSecurityProxy(issuer).issueMacaroon(job) for job in jobs]
1472 repository = code_imports[0].git_repository
1473 auth_params = _make_auth_params(
1474 LAUNCHPAD_SERVICES, macaroons[0].serialize())
1475 pushed_branch = 'branch1'
1476
1477 self.assertFault(
1478 faults.Unauthorized, None,
1479 "getMergeProposalURL",
1480 removeSecurityProxy(repository).getInternalPath(),
1481 pushed_branch, auth_params)
1482
1483 with celebrity_logged_in("vcs_imports"):
1484 getUtility(ICodeImportJobWorkflow).startJob(jobs[0], machine)
1485
1486 auth_params = _make_auth_params(
1487 LAUNCHPAD_SERVICES, macaroons[1].serialize())
1488
1489 self.assertFault(
1490 faults.Unauthorized, None,
1491 "getMergeProposalURL",
1492 removeSecurityProxy(repository).getInternalPath(),
1493 pushed_branch, auth_params)
1494
1495 auth_params = _make_auth_params(
1496 LAUNCHPAD_SERVICES, macaroon_raw=Macaroon(
1497 location=config.vhost.mainsite.hostname,
1498 identifier="another",
1499 key="another-secret").serialize())
1500
1501 self.assertFault(
1502 faults.Unauthorized, None,
1503 "getMergeProposalURL",
1504 removeSecurityProxy(repository).getInternalPath(),
1505 pushed_branch, auth_params)
1506
1507 auth_params = _make_auth_params(
1508 LAUNCHPAD_SERVICES, macaroon_raw="nonsense")
1509
1510 self.assertFault(
1511 faults.Unauthorized, None,
1512 "getMergeProposalURL",
1513 removeSecurityProxy(repository).getInternalPath(),
1514 pushed_branch, auth_params)
1515
1268 def test_notify(self):1516 def test_notify(self):
1269 # The notify call creates a GitRefScanJob.1517 # The notify call creates a GitRefScanJob.
1270 repository = self.factory.makeGitRepository()1518 repository = self.factory.makeGitRepository()

Subscribers

People subscribed via source and target branches

to status/vote changes: