Merge lp:~wgrant/launchpad/webhook-model into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 17632
Proposed branch: lp:~wgrant/launchpad/webhook-model
Merge into: lp:launchpad
Prerequisite: lp:~wgrant/launchpad/webhook-db
Diff against target: 1093 lines (+944/-2)
14 files modified
configs/testrunner/launchpad-lazr.conf (+3/-0)
database/schema/security.cfg (+9/-1)
lib/lp/security.py (+14/-0)
lib/lp/services/config/schema-lazr.conf (+11/-0)
lib/lp/services/configure.zcml (+1/-0)
lib/lp/services/webhooks/client.py (+54/-0)
lib/lp/services/webhooks/configure.zcml (+28/-0)
lib/lp/services/webhooks/interfaces.py (+153/-0)
lib/lp/services/webhooks/model.py (+252/-0)
lib/lp/services/webhooks/tests/test_webhook.py (+153/-0)
lib/lp/services/webhooks/tests/test_webhookjob.py (+253/-0)
lib/lp/testing/factory.py (+10/-0)
setup.py (+1/-0)
versions.cfg (+2/-1)
To merge this branch: bzr merge lp:~wgrant/launchpad/webhook-model
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+264004@code.launchpad.net

Commit message

Add basic webhook model for Git repositories.

Description of the change

This branch adds the initial model for webhooks.

The owner of an IWebhookTarget (currently IGitRepository) can create IWebhooks to receive events related to that target. Each event creates an IWebhookDeliveryJob (a "delivery") which is stored as a WebhookJob. A delivery may be retried many times, automatically via the normal Job retry machinery, or triggered manually by the user.

This is a bit of a skeleton. Retries and pruning will have their own branches later, and the actual delivery method is the minimum needed for testing.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'configs/testrunner/launchpad-lazr.conf'
2--- configs/testrunner/launchpad-lazr.conf 2014-01-15 22:24:31 +0000
3+++ configs/testrunner/launchpad-lazr.conf 2015-07-15 06:21:50 +0000
4@@ -180,3 +180,6 @@
5
6 [testkeyserver]
7 root: /var/tmp/testkeyserver.test
8+
9+[webhooks]
10+http_proxy: http://example.com:3128/
11
12=== modified file 'database/schema/security.cfg'
13--- database/schema/security.cfg 2015-07-13 01:12:58 +0000
14+++ database/schema/security.cfg 2015-07-15 06:21:50 +0000
15@@ -321,7 +321,8 @@
16 public.validpersonorteamcache = SELECT
17 public.vote = SELECT, INSERT, UPDATE
18 public.votecast = SELECT, INSERT
19-public.webhook = SELECT, UPDATE
20+public.webhook = SELECT, INSERT, UPDATE, DELETE
21+public.webhookjob = SELECT, INSERT, UPDATE, DELETE
22 public.wikiname = SELECT, INSERT, UPDATE, DELETE
23 type=user
24
25@@ -2451,3 +2452,10 @@
26 public.product = SELECT
27 public.productseries = SELECT
28 public.sourcepackagename = SELECT
29+
30+[webhookrunner]
31+type=user
32+groups=script
33+public.job = SELECT, UPDATE
34+public.webhook = SELECT
35+public.webhookjob = SELECT, UPDATE
36
37=== modified file 'lib/lp/security.py'
38--- lib/lp/security.py 2015-06-26 06:30:46 +0000
39+++ lib/lp/security.py 2015-07-15 06:21:50 +0000
40@@ -183,6 +183,7 @@
41 )
42 from lp.services.openid.interfaces.openididentifier import IOpenIdIdentifier
43 from lp.services.webapp.interfaces import ILaunchpadRoot
44+from lp.services.webhooks.interfaces import IWebhook
45 from lp.services.worlddata.interfaces.country import ICountry
46 from lp.services.worlddata.interfaces.language import (
47 ILanguage,
48@@ -3053,3 +3054,16 @@
49
50 class AdminLiveFSBuild(AdminByBuilddAdmin):
51 usedfor = ILiveFSBuild
52+
53+
54+class ViewWebhook(AuthorizationBase):
55+ """Webhooks can be viewed and edited by someone who can edit the target."""
56+ permission = 'launchpad.View'
57+ usedfor = IWebhook
58+
59+ def checkUnauthenticated(self):
60+ return False
61+
62+ def checkAuthenticated(self, user):
63+ return self.forwardCheckAuthenticated(
64+ user, self.obj.target, 'launchpad.Edit')
65
66=== modified file 'lib/lp/services/config/schema-lazr.conf'
67--- lib/lp/services/config/schema-lazr.conf 2015-05-27 11:04:43 +0000
68+++ lib/lp/services/config/schema-lazr.conf 2015-07-15 06:21:50 +0000
69@@ -1715,6 +1715,13 @@
70 # datatype: boolean
71 send_email: true
72
73+[webhooks]
74+# Outbound webhook request proxy. Users can use webhooks to trigger requests to
75+# arbitrary URLs with somewhat user-controlled content, and services
76+# like xmlrpc-private are restricted only by firewalls rather than
77+# credentials, so the proxy needs to be very carefully secured.
78+http_proxy: none
79+
80 [process-job-source-groups]
81 # This section is used by cronscripts/process-job-source-groups.py.
82 dbuser: process-job-source-groups
83@@ -1851,6 +1858,10 @@
84 dbuser: product-job
85 crontab_group: MAIN
86
87+[IWebhookDeliveryJobSource]
88+module: lp.services.webhooks.interfaces
89+dbuser: webhookrunner
90+
91 [job_runner_queues]
92 # The names of all queues.
93 queues: launchpad_job launchpad_job_slow bzrsyncd_job bzrsyncd_job_slow branch_write_job branch_write_job_slow celerybeat
94
95=== modified file 'lib/lp/services/configure.zcml'
96--- lib/lp/services/configure.zcml 2011-12-24 18:33:21 +0000
97+++ lib/lp/services/configure.zcml 2015-07-15 06:21:50 +0000
98@@ -31,6 +31,7 @@
99 <include package=".statistics" />
100 <include package=".temporaryblobstorage" />
101 <include package=".verification" />
102+ <include package=".webhooks" />
103 <include package=".webservice" />
104 <include package=".worlddata" />
105 </configure>
106
107=== added directory 'lib/lp/services/webhooks'
108=== added file 'lib/lp/services/webhooks/__init__.py'
109=== added file 'lib/lp/services/webhooks/client.py'
110--- lib/lp/services/webhooks/client.py 1970-01-01 00:00:00 +0000
111+++ lib/lp/services/webhooks/client.py 2015-07-15 06:21:50 +0000
112@@ -0,0 +1,54 @@
113+# Copyright 2015 Canonical Ltd. This software is licensed under the
114+# GNU Affero General Public License version 3 (see the file LICENSE).
115+
116+"""Communication with the Git hosting service."""
117+
118+__metaclass__ = type
119+__all__ = [
120+ 'WebhookClient',
121+ ]
122+
123+import requests
124+from zope.interface import implementer
125+
126+from lp.services.webhooks.interfaces import IWebhookClient
127+
128+
129+@implementer(IWebhookClient)
130+class WebhookClient:
131+
132+ def deliver(self, url, proxy, payload):
133+ """See `IWebhookClient`."""
134+ # We never want to execute a job if there's no proxy configured, as
135+ # we'd then be sending near-arbitrary requests from a trusted
136+ # machine.
137+ if proxy is None:
138+ raise Exception("No webhook proxy configured.")
139+ proxies = {'http': proxy, 'https': proxy}
140+ if not any(
141+ url.startswith("%s://" % scheme)
142+ for scheme in proxies.keys()):
143+ raise Exception("Unproxied scheme!")
144+ session = requests.Session()
145+ session.trust_env = False
146+ session.headers = {}
147+ preq = session.prepare_request(
148+ requests.Request('POST', url, json=payload))
149+ result = {
150+ 'request': {
151+ 'url': url,
152+ 'method': 'POST',
153+ 'headers': dict(preq.headers),
154+ 'body': preq.body,
155+ },
156+ }
157+ try:
158+ resp = session.send(preq, proxies=proxies)
159+ result['response'] = {
160+ 'status_code': resp.status_code,
161+ 'headers': dict(resp.headers),
162+ 'body': resp.content,
163+ }
164+ except requests.ConnectionError as e:
165+ result['connection_error'] = str(e)
166+ return result
167
168=== added file 'lib/lp/services/webhooks/configure.zcml'
169--- lib/lp/services/webhooks/configure.zcml 1970-01-01 00:00:00 +0000
170+++ lib/lp/services/webhooks/configure.zcml 2015-07-15 06:21:50 +0000
171@@ -0,0 +1,28 @@
172+<!-- Copyright 2010 Canonical Ltd. This software is licensed under the
173+ GNU Affero General Public License version 3 (see the file LICENSE).
174+-->
175+
176+<configure xmlns="http://namespaces.zope.org/zope">
177+
178+ <class class="lp.services.webhooks.model.Webhook">
179+ <require
180+ permission="launchpad.View"
181+ interface="lp.services.webhooks.interfaces.IWebhook"
182+ set_schema="lp.services.webhooks.interfaces.IWebhook"/>
183+ </class>
184+ <subscriber
185+ for="lp.services.webhooks.interfaces.IWebhook zope.lifecycleevent.interfaces.IObjectModifiedEvent"
186+ handler="lp.services.webhooks.model.webhook_modified"/>
187+
188+ <securedutility
189+ class="lp.services.webhooks.model.WebhookSource"
190+ provides="lp.services.webhooks.interfaces.IWebhookSource">
191+ <allow interface="lp.services.webhooks.interfaces.IWebhookSource"/>
192+ </securedutility>
193+
194+ <utility
195+ provides="lp.services.webhooks.interfaces.IWebhookClient"
196+ factory="lp.services.webhooks.client.WebhookClient"
197+ permission="zope.Public"/>
198+
199+</configure>
200
201=== added file 'lib/lp/services/webhooks/interfaces.py'
202--- lib/lp/services/webhooks/interfaces.py 1970-01-01 00:00:00 +0000
203+++ lib/lp/services/webhooks/interfaces.py 2015-07-15 06:21:50 +0000
204@@ -0,0 +1,153 @@
205+# Copyright 2015 Canonical Ltd. This software is licensed under the
206+# GNU Affero General Public License version 3 (see the file LICENSE).
207+
208+"""Webhook interfaces."""
209+
210+__metaclass__ = type
211+
212+__all__ = [
213+ 'IWebhook',
214+ 'IWebhookClient',
215+ 'IWebhookDeliveryJob',
216+ 'IWebhookDeliveryJobSource',
217+ 'IWebhookJob',
218+ 'IWebhookSource',
219+ ]
220+
221+from lazr.restful.declarations import exported
222+from lazr.restful.fields import Reference
223+from zope.interface import (
224+ Attribute,
225+ Interface,
226+ )
227+from zope.schema import (
228+ Bool,
229+ Datetime,
230+ Dict,
231+ Int,
232+ List,
233+ TextLine,
234+ )
235+
236+from lp import _
237+from lp.registry.interfaces.person import IPerson
238+from lp.services.job.interfaces.job import (
239+ IJob,
240+ IJobSource,
241+ IRunnableJob,
242+ )
243+
244+
245+class IWebhook(Interface):
246+
247+ id = Int(title=_("ID"), readonly=True, required=True)
248+
249+ target = exported(Reference(
250+ title=_("Target"), schema=IPerson, required=True, readonly=True,
251+ description=_("The object for which this webhook receives events.")))
252+ event_types = exported(List(
253+ TextLine(), title=_("Event types"),
254+ description=_(
255+ "The event types for which this webhook receives events."),
256+ required=True, readonly=False))
257+ registrant = exported(Reference(
258+ title=_("Registrant"), schema=IPerson, required=True, readonly=True,
259+ description=_("The person who created this webhook.")))
260+ date_created = exported(Datetime(
261+ title=_("Date created"), required=True, readonly=True))
262+ date_last_modified = exported(Datetime(
263+ title=_("Date last modified"), required=True, readonly=True))
264+
265+ delivery_url = exported(Bool(
266+ title=_("URL"), required=True, readonly=False))
267+ active = exported(Bool(
268+ title=_("Active"), required=True, readonly=False))
269+ secret = TextLine(
270+ title=_("Unique name"), required=False, readonly=True)
271+
272+
273+class IWebhookSource(Interface):
274+
275+ def new(target, registrant, delivery_url, event_types, active, secret):
276+ """Create a new webhook."""
277+
278+ def delete(hooks):
279+ """Delete a collection of webhooks."""
280+
281+ def getByID(id):
282+ """Get a webhook by its ID."""
283+
284+ def findByTarget(target):
285+ """Find all webhooks for the given target."""
286+
287+
288+class IWebhookJob(Interface):
289+ """A job related to a webhook."""
290+
291+ job = Reference(
292+ title=_("The common Job attributes."), schema=IJob,
293+ required=True, readonly=True)
294+
295+ webhook = Reference(
296+ title=_("The webhook that this job is for."),
297+ schema=IWebhook, required=True, readonly=True)
298+
299+ json_data = Attribute(_("A dict of data about the job."))
300+
301+
302+class IWebhookDeliveryJob(IRunnableJob):
303+ """A Job that delivers an event to a webhook consumer."""
304+
305+ webhook = exported(Reference(
306+ title=_("Webhook"),
307+ description=_("The webhook that this delivery is for."),
308+ schema=IWebhook, required=True, readonly=True))
309+
310+ pending = exported(Bool(
311+ title=_("Pending"),
312+ description=_("Whether a delivery attempt is in progress."),
313+ required=True, readonly=True))
314+
315+ successful = exported(Bool(
316+ title=_("Successful"),
317+ description=_(
318+ "Whether the most recent delivery attempt succeeded, or null if "
319+ "no attempts have been made yet."),
320+ required=False, readonly=True))
321+
322+ date_created = exported(Datetime(
323+ title=_("Date created"), required=True, readonly=True))
324+
325+ date_sent = exported(Datetime(
326+ title=_("Date sent"),
327+ description=_("Timestamp of the last delivery attempt."),
328+ required=False, readonly=True))
329+
330+ payload = exported(Dict(
331+ title=_('Event payload'),
332+ key_type=TextLine(), required=True, readonly=True))
333+
334+
335+class IWebhookDeliveryJobSource(IJobSource):
336+
337+ def create(webhook):
338+ """Deliver an event to a webhook consumer.
339+
340+ :param webhook: The webhook to deliver to.
341+ """
342+
343+
344+class IWebhookClient(Interface):
345+
346+ def deliver(self, url, proxy, payload):
347+ """Deliver a payload to a webhook endpoint.
348+
349+ Returns a dict of request and response details. The 'request' key
350+ and one of either 'response' or 'connection_error' are always
351+ present.
352+
353+ An exception will be raised if an internal error has occurred that
354+ cannot be the fault of the remote endpoint. For example, a 404 will
355+ return a response, and a DNS error returns a connection_error, but
356+ the proxy being offline will raise an exception.
357+ """
358
359=== added file 'lib/lp/services/webhooks/model.py'
360--- lib/lp/services/webhooks/model.py 1970-01-01 00:00:00 +0000
361+++ lib/lp/services/webhooks/model.py 2015-07-15 06:21:50 +0000
362@@ -0,0 +1,252 @@
363+# Copyright 2015 Canonical Ltd. This software is licensed under the
364+# GNU Affero General Public License version 3 (see the file LICENSE).
365+
366+__metaclass__ = type
367+
368+__all__ = [
369+ 'Webhook',
370+ 'WebhookJob',
371+ 'WebhookJobType',
372+ ]
373+
374+import datetime
375+
376+import iso8601
377+from lazr.delegates import delegate_to
378+from lazr.enum import (
379+ DBEnumeratedType,
380+ DBItem,
381+ )
382+import pytz
383+from storm.properties import (
384+ Bool,
385+ DateTime,
386+ Int,
387+ JSON,
388+ Unicode,
389+ )
390+from storm.references import Reference
391+from zope.component import getUtility
392+from zope.interface import (
393+ implementer,
394+ provider,
395+ )
396+from zope.security.proxy import removeSecurityProxy
397+
398+from lp.services.config import config
399+from lp.services.database.constants import UTC_NOW
400+from lp.services.database.enumcol import EnumCol
401+from lp.services.database.interfaces import IStore
402+from lp.services.database.stormbase import StormBase
403+from lp.services.job.model.job import (
404+ EnumeratedSubclass,
405+ Job,
406+ )
407+from lp.services.job.runner import BaseRunnableJob
408+from lp.services.webhooks.interfaces import (
409+ IWebhook,
410+ IWebhookClient,
411+ IWebhookDeliveryJob,
412+ IWebhookDeliveryJobSource,
413+ IWebhookJob,
414+ )
415+
416+
417+def webhook_modified(webhook, event):
418+ """Update the date_last_modified property when a Webhook is modified.
419+
420+ This method is registered as a subscriber to `IObjectModifiedEvent`
421+ events on Webhooks.
422+ """
423+ if event.edited_fields:
424+ removeSecurityProxy(webhook).date_last_modified = UTC_NOW
425+
426+
427+@implementer(IWebhook)
428+class Webhook(StormBase):
429+ """See `IWebhook`."""
430+
431+ __storm_table__ = 'Webhook'
432+
433+ id = Int(primary=True)
434+
435+ git_repository_id = Int(name='git_repository')
436+ git_repository = Reference(git_repository_id, 'GitRepository.id')
437+
438+ registrant_id = Int(name='registrant', allow_none=False)
439+ registrant = Reference(registrant_id, 'Person.id')
440+ date_created = DateTime(tzinfo=pytz.UTC, allow_none=False)
441+ date_last_modified = DateTime(tzinfo=pytz.UTC, allow_none=False)
442+
443+ delivery_url = Unicode(allow_none=False)
444+ active = Bool(default=True, allow_none=False)
445+ secret = Unicode(allow_none=False)
446+
447+ json_data = JSON(name='json_data')
448+
449+ @property
450+ def target(self):
451+ if self.git_repository is not None:
452+ return self.git_repository
453+ else:
454+ raise AssertionError("No target.")
455+
456+ @property
457+ def event_types(self):
458+ return (self.json_data or {}).get('event_types', [])
459+
460+ @event_types.setter
461+ def event_types(self, event_types):
462+ updated_data = self.json_data or {}
463+ assert isinstance(event_types, (list, tuple))
464+ assert all(isinstance(v, basestring) for v in event_types)
465+ updated_data['event_types'] = event_types
466+ self.json_data = updated_data
467+
468+
469+class WebhookSource:
470+ """See `IWebhookSource`."""
471+
472+ def new(self, target, registrant, delivery_url, event_types, active,
473+ secret):
474+ from lp.code.interfaces.gitrepository import IGitRepository
475+ hook = Webhook()
476+ if IGitRepository.providedBy(target):
477+ hook.git_repository = target
478+ else:
479+ raise AssertionError("Unsupported target: %r" % (target,))
480+ hook.registrant = registrant
481+ hook.delivery_url = delivery_url
482+ hook.active = active
483+ hook.secret = secret
484+ hook.event_types = event_types
485+ IStore(Webhook).add(hook)
486+ return hook
487+
488+ def delete(self, hooks):
489+ IStore(Webhook).find(
490+ Webhook, Webhook.id.is_in(set(hook.id for hook in hooks))).remove()
491+
492+ def getByID(self, id):
493+ return IStore(Webhook).get(Webhook, id)
494+
495+ def findByTarget(self, target):
496+ from lp.code.interfaces.gitrepository import IGitRepository
497+ if IGitRepository.providedBy(target):
498+ target_filter = Webhook.git_repository == target
499+ else:
500+ raise AssertionError("Unsupported target: %r" % (target,))
501+ return IStore(Webhook).find(Webhook, target_filter)
502+
503+
504+class WebhookJobType(DBEnumeratedType):
505+ """Values that `IWebhookJob.job_type` can take."""
506+
507+ DELIVERY = DBItem(0, """
508+ DELIVERY
509+
510+ This job delivers an event to a webhook's endpoint.
511+ """)
512+
513+
514+@implementer(IWebhookJob)
515+class WebhookJob(StormBase):
516+ """See `IWebhookJob`."""
517+
518+ __storm_table__ = 'WebhookJob'
519+
520+ job_id = Int(name='job', primary=True)
521+ job = Reference(job_id, 'Job.id')
522+
523+ webhook_id = Int(name='webhook', allow_none=False)
524+ webhook = Reference(webhook_id, 'Webhook.id')
525+
526+ job_type = EnumCol(enum=WebhookJobType, notNull=True)
527+
528+ json_data = JSON('json_data')
529+
530+ def __init__(self, webhook, job_type, json_data, **job_args):
531+ """Constructor.
532+
533+ Extra keyword arguments are used to construct the underlying Job
534+ object.
535+
536+ :param webhook: The `IWebhook` this job relates to.
537+ :param job_type: The `WebhookJobType` of this job.
538+ :param json_data: The type-specific variables, as a JSON-compatible
539+ dict.
540+ """
541+ super(WebhookJob, self).__init__()
542+ self.job = Job(**job_args)
543+ self.webhook = webhook
544+ self.job_type = job_type
545+ self.json_data = json_data
546+
547+ def makeDerived(self):
548+ return WebhookJobDerived.makeSubclass(self)
549+
550+
551+@delegate_to(IWebhookJob)
552+class WebhookJobDerived(BaseRunnableJob):
553+
554+ __metaclass__ = EnumeratedSubclass
555+
556+ def __init__(self, webhook_job):
557+ self.context = webhook_job
558+
559+
560+@provider(IWebhookDeliveryJobSource)
561+@implementer(IWebhookDeliveryJob)
562+class WebhookDeliveryJob(WebhookJobDerived):
563+ """A job that delivers an event to a webhook endpoint."""
564+
565+ class_job_type = WebhookJobType.DELIVERY
566+
567+ config = config.IWebhookDeliveryJobSource
568+
569+ @classmethod
570+ def create(cls, webhook, payload):
571+ webhook_job = WebhookJob(
572+ webhook, cls.class_job_type, {"payload": payload})
573+ job = cls(webhook_job)
574+ job.celeryRunOnCommit()
575+ return job
576+
577+ @property
578+ def pending(self):
579+ return self.job.is_pending
580+
581+ @property
582+ def successful(self):
583+ if 'result' not in self.json_data:
584+ return None
585+ if 'connection_error' in self.json_data['result']:
586+ return False
587+ status_code = self.json_data['result']['response']['status_code']
588+ return 200 <= status_code <= 299
589+
590+ @property
591+ def date_sent(self):
592+ if 'date_sent' not in self.json_data:
593+ return None
594+ return iso8601.parse_date(self.json_data['date_sent'])
595+
596+ @property
597+ def payload(self):
598+ return self.json_data['payload']
599+
600+ def run(self):
601+ result = getUtility(IWebhookClient).deliver(
602+ self.webhook.delivery_url, config.webhooks.http_proxy,
603+ self.payload)
604+ # Request and response headers and body may be large, so don't
605+ # store them in the frequently-used JSON. We could store them in
606+ # the librarian if we wanted them in future.
607+ for direction in ('request', 'response'):
608+ for attr in ('headers', 'body'):
609+ if direction in result and attr in result[direction]:
610+ del result[direction][attr]
611+ updated_data = self.json_data
612+ updated_data['result'] = result
613+ updated_data['date_sent'] = datetime.datetime.now(pytz.UTC).isoformat()
614+ self.json_data = updated_data
615
616=== added directory 'lib/lp/services/webhooks/tests'
617=== added file 'lib/lp/services/webhooks/tests/__init__.py'
618=== added file 'lib/lp/services/webhooks/tests/test_webhook.py'
619--- lib/lp/services/webhooks/tests/test_webhook.py 1970-01-01 00:00:00 +0000
620+++ lib/lp/services/webhooks/tests/test_webhook.py 2015-07-15 06:21:50 +0000
621@@ -0,0 +1,153 @@
622+# Copyright 2015 Canonical Ltd. This software is licensed under the
623+# GNU Affero General Public License version 3 (see the file LICENSE).
624+
625+from lazr.lifecycle.event import ObjectModifiedEvent
626+from storm.store import Store
627+from testtools.matchers import GreaterThan
628+import transaction
629+from zope.component import getUtility
630+from zope.event import notify
631+from zope.security.checker import getChecker
632+
633+from lp.services.webapp.authorization import check_permission
634+from lp.services.webhooks.interfaces import (
635+ IWebhook,
636+ IWebhookSource,
637+ )
638+from lp.testing import (
639+ admin_logged_in,
640+ anonymous_logged_in,
641+ login_person,
642+ person_logged_in,
643+ TestCaseWithFactory,
644+ )
645+from lp.testing.layers import DatabaseFunctionalLayer
646+
647+
648+class TestWebhook(TestCaseWithFactory):
649+
650+ layer = DatabaseFunctionalLayer
651+
652+ def test_modifiedevent_sets_date_last_modified(self):
653+ # When a Webhook receives an object modified event, the last modified
654+ # date is set to UTC_NOW.
655+ webhook = self.factory.makeWebhook()
656+ transaction.commit()
657+ with admin_logged_in():
658+ old_mtime = webhook.date_last_modified
659+ notify(ObjectModifiedEvent(
660+ webhook, webhook, [IWebhook["delivery_url"]]))
661+ with admin_logged_in():
662+ self.assertThat(
663+ webhook.date_last_modified,
664+ GreaterThan(old_mtime))
665+
666+
667+class TestWebhookPermissions(TestCaseWithFactory):
668+
669+ layer = DatabaseFunctionalLayer
670+
671+ def test_target_owner_can_view(self):
672+ target = self.factory.makeGitRepository()
673+ webhook = self.factory.makeWebhook(target=target)
674+ with person_logged_in(target.owner):
675+ self.assertTrue(check_permission('launchpad.View', webhook))
676+
677+ def test_random_cannot_view(self):
678+ webhook = self.factory.makeWebhook()
679+ with person_logged_in(self.factory.makePerson()):
680+ self.assertFalse(check_permission('launchpad.View', webhook))
681+
682+ def test_anonymous_cannot_view(self):
683+ webhook = self.factory.makeWebhook()
684+ with anonymous_logged_in():
685+ self.assertFalse(check_permission('launchpad.View', webhook))
686+
687+ def test_get_permissions(self):
688+ expected_get_permissions = {
689+ 'launchpad.View': set((
690+ 'active', 'date_created', 'date_last_modified', 'delivery_url',
691+ 'event_types', 'id', 'registrant', 'secret', 'target')),
692+ }
693+ webhook = self.factory.makeWebhook()
694+ checker = getChecker(webhook)
695+ self.checkPermissions(
696+ expected_get_permissions, checker.get_permissions, 'get')
697+
698+ def test_set_permissions(self):
699+ expected_set_permissions = {
700+ 'launchpad.View': set(('active', 'delivery_url', 'event_types')),
701+ }
702+ webhook = self.factory.makeWebhook()
703+ checker = getChecker(webhook)
704+ self.checkPermissions(
705+ expected_set_permissions, checker.set_permissions, 'set')
706+
707+
708+class TestWebhookSource(TestCaseWithFactory):
709+
710+ layer = DatabaseFunctionalLayer
711+
712+ def test_new(self):
713+ target = self.factory.makeGitRepository()
714+ login_person(target.owner)
715+ person = self.factory.makePerson()
716+ hook = getUtility(IWebhookSource).new(
717+ target, person, u'http://path/to/something', ['git:push'], True,
718+ u'sekrit')
719+ Store.of(hook).flush()
720+ self.assertEqual(target, hook.target)
721+ self.assertEqual(person, hook.registrant)
722+ self.assertIsNot(None, hook.date_created)
723+ self.assertEqual(hook.date_created, hook.date_last_modified)
724+ self.assertEqual(u'http://path/to/something', hook.delivery_url)
725+ self.assertEqual(True, hook.active)
726+ self.assertEqual(u'sekrit', hook.secret)
727+ self.assertEqual(['git:push'], hook.event_types)
728+
729+ def test_getByID(self):
730+ hook1 = self.factory.makeWebhook()
731+ hook2 = self.factory.makeWebhook()
732+ with admin_logged_in():
733+ self.assertEqual(
734+ hook1, getUtility(IWebhookSource).getByID(hook1.id))
735+ self.assertEqual(
736+ hook2, getUtility(IWebhookSource).getByID(hook2.id))
737+ self.assertIs(
738+ None, getUtility(IWebhookSource).getByID(1234))
739+
740+ def test_findByTarget(self):
741+ target1 = self.factory.makeGitRepository()
742+ target2 = self.factory.makeGitRepository()
743+ for target, name in ((target1, 'one'), (target2, 'two')):
744+ for i in range(3):
745+ self.factory.makeWebhook(
746+ target, u'http://path/%s/%d' % (name, i))
747+ with person_logged_in(target1.owner):
748+ self.assertContentEqual(
749+ [u'http://path/one/0', u'http://path/one/1',
750+ u'http://path/one/2'],
751+ [hook.delivery_url for hook in
752+ getUtility(IWebhookSource).findByTarget(target1)])
753+ with person_logged_in(target2.owner):
754+ self.assertContentEqual(
755+ [u'http://path/two/0', u'http://path/two/1',
756+ u'http://path/two/2'],
757+ [hook.delivery_url for hook in
758+ getUtility(IWebhookSource).findByTarget(target2)])
759+
760+ def test_delete(self):
761+ target = self.factory.makeGitRepository()
762+ login_person(target.owner)
763+ hooks = [
764+ self.factory.makeWebhook(target, u'http://path/to/%d' % i)
765+ for i in range(3)]
766+ self.assertContentEqual(
767+ [u'http://path/to/0', u'http://path/to/1', u'http://path/to/2'],
768+ [hook.delivery_url for hook in
769+ getUtility(IWebhookSource).findByTarget(target)])
770+ getUtility(IWebhookSource).delete(hooks[:2])
771+ self.assertContentEqual(
772+ [u'http://path/to/2'],
773+ [hook.delivery_url for hook in
774+ getUtility(IWebhookSource).findByTarget(target)])
775
776=== added file 'lib/lp/services/webhooks/tests/test_webhookjob.py'
777--- lib/lp/services/webhooks/tests/test_webhookjob.py 1970-01-01 00:00:00 +0000
778+++ lib/lp/services/webhooks/tests/test_webhookjob.py 2015-07-15 06:21:50 +0000
779@@ -0,0 +1,253 @@
780+# Copyright 2015 Canonical Ltd. This software is licensed under the
781+# GNU Affero General Public License version 3 (see the file LICENSE).
782+
783+"""Tests for `WebhookJob`s."""
784+
785+__metaclass__ = type
786+
787+from httmock import (
788+ HTTMock,
789+ urlmatch,
790+ )
791+import requests
792+from testtools import TestCase
793+from testtools.matchers import (
794+ Contains,
795+ ContainsDict,
796+ Equals,
797+ Is,
798+ KeysEqual,
799+ MatchesAll,
800+ MatchesStructure,
801+ Not,
802+ )
803+
804+from lp.services.job.interfaces.job import JobStatus
805+from lp.services.job.runner import JobRunner
806+from lp.services.webhooks.client import WebhookClient
807+from lp.services.webhooks.interfaces import (
808+ IWebhookClient,
809+ IWebhookDeliveryJob,
810+ IWebhookJob,
811+ )
812+from lp.services.webhooks.model import (
813+ WebhookDeliveryJob,
814+ WebhookJob,
815+ WebhookJobDerived,
816+ WebhookJobType,
817+ )
818+from lp.testing import TestCaseWithFactory
819+from lp.testing.dbuser import dbuser
820+from lp.testing.fixture import (
821+ CaptureOops,
822+ ZopeUtilityFixture,
823+ )
824+from lp.testing.layers import (
825+ DatabaseFunctionalLayer,
826+ LaunchpadZopelessLayer,
827+ )
828+
829+
830+class TestWebhookJob(TestCaseWithFactory):
831+ """Tests for `WebhookJob`."""
832+
833+ layer = DatabaseFunctionalLayer
834+
835+ def test_provides_interface(self):
836+ # `WebhookJob` objects provide `IWebhookJob`.
837+ hook = self.factory.makeWebhook()
838+ self.assertProvides(
839+ WebhookJob(hook, WebhookJobType.DELIVERY, {}), IWebhookJob)
840+
841+
842+class TestWebhookJobDerived(TestCaseWithFactory):
843+ """Tests for `WebhookJobDerived`."""
844+
845+ layer = LaunchpadZopelessLayer
846+
847+ def test_getOopsMailController(self):
848+ """By default, no mail is sent about failed WebhookJobs."""
849+ hook = self.factory.makeWebhook()
850+ job = WebhookJob(hook, WebhookJobType.DELIVERY, {})
851+ derived = WebhookJobDerived(job)
852+ self.assertIsNone(derived.getOopsMailController("x"))
853+
854+
855+class TestWebhookClient(TestCase):
856+ """Tests for `WebhookClient`."""
857+
858+ def sendToWebhook(self, response_status=200, raises=None):
859+ reqs = []
860+
861+ @urlmatch(netloc='hookep.com')
862+ def endpoint_mock(url, request):
863+ if raises:
864+ raise raises
865+ reqs.append(request)
866+ return {'status_code': response_status, 'content': 'Content'}
867+
868+ with HTTMock(endpoint_mock):
869+ result = WebhookClient().deliver(
870+ 'http://hookep.com/foo',
871+ {'http': 'http://squid.example.com:3128'},
872+ {'foo': 'bar'})
873+
874+ return reqs, result
875+
876+ def test_sends_request(self):
877+ [request], result = self.sendToWebhook()
878+ self.assertEqual(
879+ {'Content-Type': 'application/json', 'Content-Length': '14'},
880+ result['request']['headers'])
881+ self.assertEqual('{"foo": "bar"}', result['request']['body'])
882+ self.assertEqual(200, result['response']['status_code'])
883+ self.assertEqual({}, result['response']['headers'])
884+ self.assertEqual('Content', result['response']['body'])
885+
886+ def test_accepts_404(self):
887+ [request], result = self.sendToWebhook(response_status=404)
888+ self.assertEqual(
889+ {'Content-Type': 'application/json', 'Content-Length': '14'},
890+ result['request']['headers'])
891+ self.assertEqual('{"foo": "bar"}', result['request']['body'])
892+ self.assertEqual(404, result['response']['status_code'])
893+ self.assertEqual({}, result['response']['headers'])
894+ self.assertEqual('Content', result['response']['body'])
895+
896+ def test_connection_error(self):
897+ # Attempts that fail to connect have a connection_error rather
898+ # than a response.
899+ reqs, result = self.sendToWebhook(
900+ raises=requests.ConnectionError('Connection refused'))
901+ self.assertNotIn('response', result)
902+ self.assertEqual(
903+ 'Connection refused', result['connection_error'])
904+ self.assertEqual([], reqs)
905+
906+
907+class MockWebhookClient:
908+
909+ def __init__(self, response_status=200, raises=None):
910+ self.response_status = response_status
911+ self.raises = raises
912+ self.requests = []
913+
914+ def deliver(self, url, proxy, payload):
915+ result = {'request': {}}
916+ if isinstance(self.raises, requests.ConnectionError):
917+ result['connection_error'] = str(self.raises)
918+ elif self.raises is not None:
919+ raise self.raises
920+ else:
921+ self.requests.append(('POST', url))
922+ result['response'] = {'status_code': self.response_status}
923+ return result
924+
925+
926+class TestWebhookDeliveryJob(TestCaseWithFactory):
927+ """Tests for `WebhookDeliveryJob`."""
928+
929+ layer = LaunchpadZopelessLayer
930+
931+ def makeAndRunJob(self, response_status=200, raises=None, mock=True):
932+ hook = self.factory.makeWebhook(delivery_url=u'http://hookep.com/foo')
933+ job = WebhookDeliveryJob.create(hook, payload={'foo': 'bar'})
934+
935+ client = MockWebhookClient(
936+ response_status=response_status, raises=raises)
937+ if mock:
938+ self.useFixture(ZopeUtilityFixture(client, IWebhookClient))
939+ with dbuser("webhookrunner"):
940+ JobRunner([job]).runAll()
941+ return job, client.requests
942+
943+ def test_provides_interface(self):
944+ # `WebhookDeliveryJob` objects provide `IWebhookDeliveryJob`.
945+ hook = self.factory.makeWebhook()
946+ self.assertProvides(
947+ WebhookDeliveryJob.create(hook, payload={}), IWebhookDeliveryJob)
948+
949+ def test_run_200(self):
950+ # A request that returns 200 is a success.
951+ with CaptureOops() as oopses:
952+ job, reqs = self.makeAndRunJob(response_status=200)
953+ self.assertThat(
954+ job,
955+ MatchesStructure(
956+ status=Equals(JobStatus.COMPLETED),
957+ pending=Equals(False),
958+ successful=Equals(True),
959+ date_sent=Not(Is(None)),
960+ json_data=ContainsDict(
961+ {'result': MatchesAll(
962+ KeysEqual('request', 'response'),
963+ ContainsDict(
964+ {'response': ContainsDict(
965+ {'status_code': Equals(200)})}))})))
966+ self.assertEqual(1, len(reqs))
967+ self.assertEqual([('POST', 'http://hookep.com/foo')], reqs)
968+ self.assertEqual([], oopses.oopses)
969+
970+ def test_run_404(self):
971+ # The job succeeds even if the response is an error. A job only
972+ # fails if it was definitely a problem on our end.
973+ with CaptureOops() as oopses:
974+ job, reqs = self.makeAndRunJob(response_status=404)
975+ self.assertThat(
976+ job,
977+ MatchesStructure(
978+ status=Equals(JobStatus.COMPLETED),
979+ pending=Equals(False),
980+ successful=Equals(False),
981+ date_sent=Not(Is(None)),
982+ json_data=ContainsDict(
983+ {'result': MatchesAll(
984+ KeysEqual('request', 'response'),
985+ ContainsDict(
986+ {'response': ContainsDict(
987+ {'status_code': Equals(404)})}))})))
988+ self.assertEqual(1, len(reqs))
989+ self.assertEqual([], oopses.oopses)
990+
991+ def test_run_connection_error(self):
992+ # Jobs that fail to connecthave a connection_error rather than a
993+ # response.
994+ with CaptureOops() as oopses:
995+ job, reqs = self.makeAndRunJob(
996+ raises=requests.ConnectionError('Connection refused'))
997+ self.assertThat(
998+ job,
999+ MatchesStructure(
1000+ status=Equals(JobStatus.COMPLETED),
1001+ pending=Equals(False),
1002+ successful=Equals(False),
1003+ date_sent=Not(Is(None)),
1004+ json_data=ContainsDict(
1005+ {'result': MatchesAll(
1006+ KeysEqual('request', 'connection_error'),
1007+ ContainsDict(
1008+ {'connection_error': Equals('Connection refused')})
1009+ )})))
1010+ self.assertEqual([], reqs)
1011+ self.assertEqual([], oopses.oopses)
1012+
1013+ def test_run_no_proxy(self):
1014+ # Since users can cause the webhook runner to make somewhat
1015+ # controlled POST requests to arbitrary URLs, they're forced to
1016+ # go through a locked-down HTTP proxy. If none is configured,
1017+ # the job crashes.
1018+ self.pushConfig('webhooks', http_proxy=None)
1019+ with CaptureOops() as oopses:
1020+ job, reqs = self.makeAndRunJob(response_status=200, mock=False)
1021+ self.assertThat(
1022+ job,
1023+ MatchesStructure(
1024+ status=Equals(JobStatus.FAILED),
1025+ pending=Equals(False),
1026+ successful=Is(None),
1027+ date_sent=Is(None),
1028+ json_data=Not(Contains('result'))))
1029+ self.assertEqual([], reqs)
1030+ self.assertEqual(1, len(oopses.oopses))
1031+ self.assertEqual(
1032+ 'No webhook proxy configured.', oopses.oopses[0]['value'])
1033
1034=== modified file 'lib/lp/testing/factory.py'
1035--- lib/lp/testing/factory.py 2015-06-25 04:42:48 +0000
1036+++ lib/lp/testing/factory.py 2015-07-15 06:21:50 +0000
1037@@ -261,6 +261,7 @@
1038 from lp.services.utils import AutoDecorate
1039 from lp.services.webapp.interfaces import OAuthPermission
1040 from lp.services.webapp.sorting import sorted_version_numbers
1041+from lp.services.webhooks.interfaces import IWebhookSource
1042 from lp.services.worlddata.interfaces.country import ICountrySet
1043 from lp.services.worlddata.interfaces.language import ILanguageSet
1044 from lp.soyuz.adapters.overrides import SourceOverride
1045@@ -4521,6 +4522,15 @@
1046 return ProxyFactory(
1047 LiveFSFile(livefsbuild=livefsbuild, libraryfile=libraryfile))
1048
1049+ def makeWebhook(self, target=None, delivery_url=None):
1050+ if target is None:
1051+ target = self.makeGitRepository()
1052+ if delivery_url is None:
1053+ delivery_url = self.getUniqueURL().decode('utf-8')
1054+ return getUtility(IWebhookSource).new(
1055+ target, self.makePerson(), delivery_url, [], True,
1056+ self.getUniqueUnicode())
1057+
1058
1059 # Some factory methods return simple Python types. We don't add
1060 # security wrappers for them, as well as for objects created by
1061
1062=== modified file 'setup.py'
1063--- setup.py 2015-03-03 01:36:19 +0000
1064+++ setup.py 2015-07-15 06:21:50 +0000
1065@@ -41,6 +41,7 @@
1066 'feedvalidator',
1067 'funkload',
1068 'html5browser',
1069+ 'httmock',
1070 'pygpgme',
1071 'python-debian',
1072 'python-keystoneclient',
1073
1074=== modified file 'versions.cfg'
1075--- versions.cfg 2015-07-09 20:01:11 +0000
1076+++ versions.cfg 2015-07-15 06:21:50 +0000
1077@@ -36,6 +36,7 @@
1078 funkload = 1.16.1
1079 grokcore.component = 1.6
1080 html5browser = 0.0.9
1081+httmock = 1.2.3
1082 httplib2 = 0.8
1083 importlib = 1.0.2
1084 ipython = 0.13.2
1085@@ -107,7 +108,7 @@
1086 python-swiftclient = 1.5.0
1087 PyYAML = 3.10
1088 rabbitfixture = 0.3.6
1089-requests = 2.5.1
1090+requests = 2.7.0
1091 s4 = 0.1.2
1092 setproctitle = 1.1.7
1093 setuptools-git = 1.0