plone.restapi/unify-expired-and-excluded-items-handling: Merge branch 'master' into unify-expired-and-excluded-items-

GitHub <jenkins-z4DKO/[email protected]> Sat, 29 Jul 2017 16:32:31 -0700 (PDT)
Newsgroups gmane.comp.web.zope.plone.cvs
Message-ID <[email protected]>
Repository: plone.restapi
Branch: refs/heads/unify-expired-and-excluded-items-handling
Date: 2017-07-30T01:31:17+02:00
Author: Timo Stollenwerk (tisto) <[email protected]>
Commit: https://github.com/plone/plone.restapi/commit/c85659f5dac7f9f7f4a6e9fd5e962d55968cd160

Merge branch 'master' into unify-expired-and-excluded-items-handling

Files changed:
A docs/source/_json/lock.req
A docs/source/_json/lock.resp
A docs/source/_json/lock_get.req
A docs/source/_json/lock_get.resp
A docs/source/_json/lock_nonstealable_timeout.req
A docs/source/_json/lock_nonstealable_timeout.resp
A docs/source/_json/lock_update.req
A docs/source/_json/lock_update.resp
A docs/source/_json/refresh_lock.req
A docs/source/_json/refresh_lock.resp
A docs/source/_json/unlock.req
A docs/source/_json/unlock.resp
A docs/source/locking.rst
A src/plone/restapi/services/locking/__init__.py
A src/plone/restapi/services/locking/configure.zcml
A src/plone/restapi/services/locking/locking.py
A src/plone/restapi/tests/test_locking.py
M CHANGES.rst
M docs/source/_json/workflow_post.resp
M docs/source/index.rst
M src/plone/restapi/services/configure.zcml
M src/plone/restapi/services/content/update.py
M src/plone/restapi/services/workflow/info.py
M src/plone/restapi/services/workflow/transition.py
M src/plone/restapi/tests/test_documentation.py

diff --git a/CHANGES.rst b/CHANGES.rst
index 87a7d6e4..b07fdd3d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -9,6 +9,9 @@ New Features:
 - Add support for expandable elements.
   [buchi]
 
+- Translate titles in @workflow.
+  [csenger]
+  
 - Add skipped tests from @breadcrumbs and @navigation now that the expansion is in place
   [sneridagh]
 
@@ -16,6 +19,9 @@ New Features:
   it on @search via the show_inactive parameter
   [sneridagh]
 
+- Add endpoints for locking/unlocking.
+  [buchi]
+
 
 1.0a20 (2017-07-24)
 -------------------
diff --git a/docs/source/_json/lock.req b/docs/source/_json/lock.req
new file mode 100644
index 00000000..0cba19bb
--- /dev/null
+++ b/docs/source/_json/lock.req
@@ -0,0 +1,3 @@
+POST /plone/front-page/@lock HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/docs/source/_json/lock.resp b/docs/source/_json/lock.resp
new file mode 100644
index 00000000..06da01d4
--- /dev/null
+++ b/docs/source/_json/lock.resp
@@ -0,0 +1,12 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+  "creator": "admin", 
+  "locked": true, 
+  "name": "plone.locking.stealable", 
+  "stealable": true, 
+  "time": 1477076400.0, 
+  "timeout": 600, 
+  "token": "0.684672730996-0.25195226375-00105A989226:1477076400.000"
+}
\ No newline at end of file
diff --git a/docs/source/_json/lock_get.req b/docs/source/_json/lock_get.req
new file mode 100644
index 00000000..43cd7c0e
--- /dev/null
+++ b/docs/source/_json/lock_get.req
@@ -0,0 +1,3 @@
+GET /plone/front-page/@lock HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/docs/source/_json/lock_get.resp b/docs/source/_json/lock_get.resp
new file mode 100644
index 00000000..52852385
--- /dev/null
+++ b/docs/source/_json/lock_get.resp
@@ -0,0 +1,7 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+  "locked": false, 
+  "stealable": true
+}
\ No newline at end of file
diff --git a/docs/source/_json/lock_nonstealable_timeout.req b/docs/source/_json/lock_nonstealable_timeout.req
new file mode 100644
index 00000000..40383b76
--- /dev/null
+++ b/docs/source/_json/lock_nonstealable_timeout.req
@@ -0,0 +1,9 @@
+POST /plone/front-page/@lock HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+    "stealable": false,
+    "timeout": 3600
+}
\ No newline at end of file
diff --git a/docs/source/_json/lock_nonstealable_timeout.resp b/docs/source/_json/lock_nonstealable_timeout.resp
new file mode 100644
index 00000000..e3927240
--- /dev/null
+++ b/docs/source/_json/lock_nonstealable_timeout.resp
@@ -0,0 +1,12 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+  "creator": "admin", 
+  "locked": true, 
+  "name": "plone.locking.stealable", 
+  "stealable": true, 
+  "time": 1477076400.0, 
+  "timeout": 3600, 
+  "token": "0.684672730996-0.25195226375-00105A989226:1477076400.000"
+}
\ No newline at end of file
diff --git a/docs/source/_json/lock_update.req b/docs/source/_json/lock_update.req
new file mode 100644
index 00000000..b9e39d84
--- /dev/null
+++ b/docs/source/_json/lock_update.req
@@ -0,0 +1,9 @@
+PATCH /plone/front-page HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Lock-Token: 0.684672730996-0.25195226375-00105A989226:1477076400.000
+Content-Type: application/json
+
+{
+    "title": "New Title"
+}
\ No newline at end of file
diff --git a/docs/source/_json/lock_update.resp b/docs/source/_json/lock_update.resp
new file mode 100644
index 00000000..0074ded3
--- /dev/null
+++ b/docs/source/_json/lock_update.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/docs/source/_json/refresh_lock.req b/docs/source/_json/refresh_lock.req
new file mode 100644
index 00000000..f1268de1
--- /dev/null
+++ b/docs/source/_json/refresh_lock.req
@@ -0,0 +1,3 @@
+POST /plone/front-page/@refresh-lock HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/docs/source/_json/refresh_lock.resp b/docs/source/_json/refresh_lock.resp
new file mode 100644
index 00000000..06da01d4
--- /dev/null
+++ b/docs/source/_json/refresh_lock.resp
@@ -0,0 +1,12 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+  "creator": "admin", 
+  "locked": true, 
+  "name": "plone.locking.stealable", 
+  "stealable": true, 
+  "time": 1477076400.0, 
+  "timeout": 600, 
+  "token": "0.684672730996-0.25195226375-00105A989226:1477076400.000"
+}
\ No newline at end of file
diff --git a/docs/source/_json/unlock.req b/docs/source/_json/unlock.req
new file mode 100644
index 00000000..8ea9689a
--- /dev/null
+++ b/docs/source/_json/unlock.req
@@ -0,0 +1,3 @@
+POST /plone/front-page/@unlock HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/docs/source/_json/unlock.resp b/docs/source/_json/unlock.resp
new file mode 100644
index 00000000..52852385
--- /dev/null
+++ b/docs/source/_json/unlock.resp
@@ -0,0 +1,7 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+  "locked": false, 
+  "stealable": true
+}
\ No newline at end of file
diff --git a/docs/source/_json/workflow_post.resp b/docs/source/_json/workflow_post.resp
index 96325403..a6ad4a79 100644
--- a/docs/source/_json/workflow_post.resp
+++ b/docs/source/_json/workflow_post.resp
@@ -6,5 +6,6 @@ Content-Type: application/json
   "actor": "admin", 
   "comments": "", 
   "review_state": "published", 
-  "time": "2016-10-21T19:05:00+00:00"
+  "time": "2016-10-21T19:05:00+00:00", 
+  "title": "Published"
 }
\ No newline at end of file
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ea4278fd..a0d8af72 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -28,6 +28,7 @@ Contents
    copymove
    expansion
    workflow
+   locking
    sharing
    registry
    types
diff --git a/docs/source/locking.rst b/docs/source/locking.rst
new file mode 100644
index 00000000..56097e06
--- /dev/null
+++ b/docs/source/locking.rst
@@ -0,0 +1,92 @@
+Locking
+=======
+
+Locking is a mechanism to prevent users from accidentially overriding each others changes.
+
+When a user edits a content object in Plone, the object is locked until the user hits the save or cancel button.
+If a second user tries to edit the object at the same time, she will see a message that this object is locked.
+
+
+Locking an object
+-----------------
+
+To lock an object send a POST request to the ``/@lock`` endpoint that is available on any content object in Plone:
+
+..  http:example:: curl httpie python-requests
+    :request: _json/lock.req
+
+If the lock operation succeeds, the server will respond with status :term:`200 OK` and return various information
+about the lock including the lock token. The token is needed in later requests to update the locked object.
+
+.. literalinclude:: _json/lock.resp
+   :language: http
+
+
+By default, locks are stealable. That means that another user can unlock the object.
+If you want to create a non-stealable lock, pass ``"stealable": false`` in the request body.
+
+To create a lock with a non-default timeout, you can pass the the timeout value in seconds in the request body.
+
+The following example creates a non-stealable lock with a timeout of 1h.
+
+..  http:example:: curl httpie python-requests
+    :request: _json/lock_nonstealable_timeout.req
+
+
+The server responds with status :term:`200 OK` and returns the lock information.
+
+.. literalinclude:: _json/lock_nonstealable_timeout.resp
+   :language: http
+
+
+Unlocking an object
+-------------------
+
+To unlock an object send a POST request to the ``/@unlock`` endpoint.
+
+..  http:example:: curl httpie python-requests
+    :request: _json/unlock.req
+
+The server responds with status :term:`200 OK` and returns the lock information.
+
+.. literalinclude:: _json/unlock.resp
+   :language: http
+
+
+Refreshing a lock
+-----------------
+
+An existing lock can be refreshed by sending a POST request to the ``@refresh-lock`` endpoint.
+
+..  http:example:: curl httpie python-requests
+    :request: _json/refresh_lock.req
+
+The server responds with status :term:`200 OK` and returns the lock information containing the updated creation time.
+
+.. literalinclude:: _json/refresh_lock.resp
+   :language: http
+
+
+Getting lock information
+------------------------
+
+To find out if an object is locked or to get information about the current lock
+you can send a GET request to the ``@lock`` endpoint.
+
+..  http:example:: curl httpie python-requests
+    :request: _json/lock_get.req
+
+The server responds with status :term:`200 OK` and returns the information about the lock.
+
+.. literalinclude:: _json/lock_get.resp
+   :language: http
+
+
+Updating a locked object
+------------------------
+
+To update a locked object with a PATCH request, you have to provide the lock
+token with the ``Lock-Token`` header.
+
+..  http:example:: curl httpie python-requests
+    :request: _json/lock_update.req
diff --git a/src/plone/restapi/services/configure.zcml b/src/plone/restapi/services/configure.zcml
index 800995bb..cdce2f5f 100644
--- a/src/plone/restapi/services/configure.zcml
+++ b/src/plone/restapi/services/configure.zcml
@@ -10,6 +10,7 @@
   <include package=".groups"/>
   <include package=".navigation"/>
   <include package=".history"/>
+  <include package=".locking" />
   <include package=".principals"/>
   <include package=".registry"/>
   <include package=".roles"/>
diff --git a/src/plone/restapi/services/content/update.py b/src/plone/restapi/services/content/update.py
index d394bd4c..19d2eb31 100644
--- a/src/plone/restapi/services/content/update.py
+++ b/src/plone/restapi/services/content/update.py
@@ -2,6 +2,7 @@
 from plone.restapi.exceptions import DeserializationError
 from plone.restapi.interfaces import IDeserializeFromJson
 from plone.restapi.services import Service
+from plone.restapi.services.locking.locking import is_locked
 from zope.component import queryMultiAdapter
 
 
@@ -10,6 +11,12 @@ class ContentPatch(Service):
     """
 
     def reply(self):
+
+        if is_locked(self.context, self.request):
+            self.request.response.setStatus(403)
+            return dict(error=dict(
+                type='Forbidden', message='Resource is locked.'))
+
         deserializer = queryMultiAdapter((self.context, self.request),
                                          IDeserializeFromJson)
         if deserializer is None:
diff --git a/src/plone/restapi/services/locking/__init__.py b/src/plone/restapi/services/locking/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/plone/restapi/services/locking/configure.zcml b/src/plone/restapi/services/locking/configure.zcml
new file mode 100644
index 00000000..d3eb4c94
--- /dev/null
+++ b/src/plone/restapi/services/locking/configure.zcml
@@ -0,0 +1,38 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:plone="http://namespaces.plone.org/plone"
+    xmlns:zcml="http://namespaces.zope.org/zcml">
+
+  <plone:service
+    method="POST"
+    name="@lock"
+    for="Products.CMFCore.interfaces.IContentish"
+    factory=".locking.Lock"
+    permission="cmf.ModifyPortalContent"
+    />
+
+  <plone:service
+    method="POST"
+    name="@unlock"
+    for="Products.CMFCore.interfaces.IContentish"
+    factory=".locking.Unlock"
+    permission="cmf.ModifyPortalContent"
+    />
+
+  <plone:service
+    method="POST"
+    name="@refresh-lock"
+    for="Products.CMFCore.interfaces.IContentish"
+    factory=".locking.RefreshLock"
+    permission="cmf.ModifyPortalContent"
+    />
+
+  <plone:service
+    method="GET"
+    name="@lock"
+    for="Products.CMFCore.interfaces.IContentish"
+    factory=".locking.LockInfo"
+    permission="zope2.View"
+    />
+
+</configure>
diff --git a/src/plone/restapi/services/locking/locking.py b/src/plone/restapi/services/locking/locking.py
new file mode 100644
index 00000000..9037879e
--- /dev/null
+++ b/src/plone/restapi/services/locking/locking.py
@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+from plone.locking.interfaces import ILockable
+from plone.locking.interfaces import INonStealableLock
+from plone.locking.interfaces import IRefreshableLockable
+from plone.restapi.deserializer import json_body
+from plone.restapi.services import Service
+from zope.interface import alsoProvides
+from zope.interface import noLongerProvides
+
+import plone.protect.interfaces
+
+
+class Lock(Service):
+    """Lock an object"""
+
+    def reply(self):
+        data = json_body(self.request)
+
+        lockable = IRefreshableLockable(self.context, None)
+        if lockable is not None:
+            lockable.lock()
+
+            if 'stealable' in data and not data['stealable']:
+                alsoProvides(self.context, INonStealableLock)
+
+            if 'timeout' in data:
+                lock_item = webdav_lock(self.context)
+                lock_item.setTimeout("Second-%s" % data['timeout'])
+
+            # Disable CSRF protection
+            if 'IDisableCSRFProtection' in dir(plone.protect.interfaces):
+                alsoProvides(self.request,
+                             plone.protect.interfaces.IDisableCSRFProtection)
+
+        return lock_info(self.context)
+
+
+class Unlock(Service):
+    """Unlock an object"""
+
+    def reply(self):
+        lockable = ILockable(self.context)
+        if lockable.can_safely_unlock():
+            lockable.unlock()
+
+            if INonStealableLock.providedBy(self.context):
+                noLongerProvides(self.context, INonStealableLock)
+
+            # Disable CSRF protection
+            if 'IDisableCSRFProtection' in dir(plone.protect.interfaces):
+                alsoProvides(self.request,
+                             plone.protect.interfaces.IDisableCSRFProtection)
+
+        return lock_info(self.context)
+
+
+class RefreshLock(Service):
+    """Refresh the lock of an object"""
+
+    def reply(self):
+        lockable = IRefreshableLockable(self.context, None)
+        if lockable is not None:
+            lockable.refresh_lock()
+
+            # Disable CSRF protection
+            if 'IDisableCSRFProtection' in dir(plone.protect.interfaces):
+                alsoProvides(self.request,
+                             plone.protect.interfaces.IDisableCSRFProtection)
+
+        return lock_info(self.context)
+
+
+class LockInfo(Service):
+    """Lock information about the current lock"""
+
+    def reply(self):
+        return lock_info(self.context)
+
+
+def lock_info(obj):
+    """Returns lock information about the given object."""
+    lockable = ILockable(obj, None)
+    if lockable is not None:
+        info = {
+            'locked': lockable.locked(),
+            'stealable': lockable.stealable(),
+        }
+        lock_info = lockable.lock_info()
+        if len(lock_info) > 0:
+            info['creator'] = lock_info[0]['creator']
+            info['time'] = lock_info[0]['time']
+            info['token'] = lock_info[0]['token']
+            lock_type = lock_info[0]['type']
+            if lock_type:
+                info['name'] = lock_info[0]['type'].__name__
+            lock_item = webdav_lock(obj)
+            if lock_item:
+                info['timeout'] = lock_item.getTimeout()
+        return info
+
+
+def webdav_lock(obj):
+    """Returns the WebDAV LockItem"""
+    lockable = ILockable(obj, None)
+    if lockable is None:
+        return
+
+    lock_info = lockable.lock_info()
+    if len(lock_info) > 0:
+        token = lock_info[0]['token']
+        return obj.wl_getLock(token)
+
+
+def is_locked(obj, request):
+    """Returns true if the object is locked and the request doesn't contain
+       the lock token.
+    """
+    lockable = ILockable(obj, None)
+    if lockable is None:
+        return False
+    if lockable.locked():
+        token = request.getHeader('Lock-Token', '')
+        lock_info = lockable.lock_info()
+        if len(lock_info) > 0 and lock_info[0]['token'] == token:
+            return False
+        return True
+    return False
diff --git a/src/plone/restapi/services/workflow/info.py b/src/plone/restapi/services/workflow/info.py
index a8fb73f4..4b8e2063 100644
--- a/src/plone/restapi/services/workflow/info.py
+++ b/src/plone/restapi/services/workflow/info.py
@@ -38,13 +38,14 @@ def __call__(self, expand=False):
             transitions.append({
                 '@id': '{}/@workflow/{}'.format(
                     self.context.absolute_url(), action['id']),
-                'title': action['title'],
+                'title': self.context.translate(action['title']),
             })
 
         for item, action in enumerate(history):
-            history[item]['title'] = wftool.getTitleForStateOnType(
-                action['review_state'],
-                self.context.portal_type)
+            history[item]['title'] = self.context.translate(
+                wftool.getTitleForStateOnType(
+                    action['review_state'],
+                    self.context.portal_type))
 
         result['workflow'].update({
             'history': json_compatible(history),
diff --git a/src/plone/restapi/services/workflow/transition.py b/src/plone/restapi/services/workflow/transition.py
index 8f4fc940..fa8d1910 100644
--- a/src/plone/restapi/services/workflow/transition.py
+++ b/src/plone/restapi/services/workflow/transition.py
@@ -59,4 +59,10 @@ def reply(self):
                 message=translate(e.message, context=self.request)))
 
         history = wftool.getInfoFor(self.context, "review_history")
-        return json_compatible(history[-1])
+        action = history[-1]
+        action['title'] = self.context.translate(
+            wftool.getTitleForStateOnType(
+                action['review_state'],
+                self.context.portal_type))
+
+        return json_compatible(action)
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index cdbc8c3d..bb28114b 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -15,6 +15,7 @@
 from plone.app.testing import pushGlobalRegistry
 from plone.app.testing import setRoles
 from plone.app.textfield.value import RichTextValue
+from plone.locking.interfaces import ITTWLockable
 from plone.namedfile.file import NamedBlobFile
 from plone.namedfile.file import NamedBlobImage
 from plone.registry.interfaces import IRegistry
@@ -24,6 +25,7 @@
 from plone.testing.z2 import Browser
 from zope.component import createObject
 from zope.component import getUtility
+from zope.interface import alsoProvides
 from zope.site.hooks import getSite
 
 import collections
@@ -48,6 +50,7 @@
 REQUEST_HEADER_KEYS = [
     'accept',
     'authorization',
+    'lock-token',
 ] + TUS_HEADERS
 
 RESPONSE_HEADER_KEYS = [
@@ -142,6 +145,7 @@ def setUp(self):
 
         setRoles(self.portal, TEST_USER_ID, ['Manager'])
         self.document = self.create_document()
+        alsoProvides(self.document, ITTWLockable)
 
         transaction.commit()
         self.browser = Browser(self.app)
@@ -978,6 +982,68 @@ def test_tusreplace_post_patch(self):
             upload_url.split('/')[:-1] + ['4e465958b24a46ec8657e6f3be720991'])
         save_request_and_response_for_docs('tusreplace_patch', response)
 
+    def test_locking_lock(self):
+        url = '{}/@lock'.format(self.document.absolute_url())
+        response = self.api_session.post(url)
+        # Replace dynamic lock token with a static one
+        response._content = re.sub(
+            r'"token": "[^"]+"',
+            '"token": "0.684672730996-0.25195226375-00105A989226:1477076400.000"',  # noqa
+            response.content)
+        save_request_and_response_for_docs('lock', response)
+
+    def test_locking_lock_nonstealable_and_timeout(self):
+        url = '{}/@lock'.format(self.document.absolute_url())
+        response = self.api_session.post(
+            url,
+            json={
+                'stealable': False,
+                'timeout': 3600,
+            },
+        )
+        # Replace dynamic lock token with a static one
+        response._content = re.sub(
+            r'"token": "[^"]+"',
+            '"token": "0.684672730996-0.25195226375-00105A989226:1477076400.000"',  # noqa
+            response.content)
+        save_request_and_response_for_docs(
+            'lock_nonstealable_timeout', response)
+
+    def test_locking_unlock(self):
+        url = '{}/@lock'.format(self.document.absolute_url())
+        response = self.api_session.post(url)
+        url = '{}/@unlock'.format(self.document.absolute_url())
+        response = self.api_session.post(url)
+        save_request_and_response_for_docs('unlock', response)
+
+    def test_locking_refresh_lock(self):
+        url = '{}/@lock'.format(self.document.absolute_url())
+        response = self.api_session.post(url)
+        url = '{}/@refresh-lock'.format(self.document.absolute_url())
+        response = self.api_session.post(url)
+        # Replace dynamic lock token with a static one
+        response._content = re.sub(
+            r'"token": "[^"]+"',
+            '"token": "0.684672730996-0.25195226375-00105A989226:1477076400.000"',  # noqa
+            response.content)
+        save_request_and_response_for_docs('refresh_lock', response)
+
+    def test_locking_lockinfo(self):
+        url = '{}/@lock'.format(self.document.absolute_url())
+        response = self.api_session.get(url)
+        save_request_and_response_for_docs('lock_get', response)
+
+    def test_update_with_lock(self):
+        url = '{}/@lock'.format(self.document.absolute_url())
+        response = self.api_session.post(url)
+        token = response.json()['token']
+        response = self.api_session.patch(
+            self.document.absolute_url(),
+            headers={'Lock-Token': token},
+            json={'title': 'New Title'})
+        response.request.headers['Lock-Token'] = u"0.684672730996-0.25195226375-00105A989226:1477076400.000"  # noqa
+        save_request_and_response_for_docs('lock_update', response)
+
 
 class TestCommenting(unittest.TestCase):
 
diff --git a/src/plone/restapi/tests/test_locking.py b/src/plone/restapi/tests/test_locking.py
new file mode 100644
index 00000000..0e7b49fb
--- /dev/null
+++ b/src/plone/restapi/tests/test_locking.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+from plone.app.testing import SITE_OWNER_NAME
+from plone.app.testing import SITE_OWNER_PASSWORD
+from plone.app.testing import login
+from plone.locking.interfaces import ITTWLockable
+from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+from plone.restapi.testing import RelativeSession
+from zope.interface import alsoProvides
+from plone.locking.interfaces import ILockable
+from plone.locking.interfaces import INonStealableLock
+
+
+import unittest
+import transaction
+
+
+class TestLocking(unittest.TestCase):
+
+    layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+
+    def setUp(self):
+        self.portal = self.layer['portal']
+        self.request = self.layer['request']
+        login(self.portal, SITE_OWNER_NAME)
+        self.doc = self.portal[self.portal.invokeFactory(
+            'Document', id='doc1', title='My Document')]
+        alsoProvides(self.doc, ITTWLockable)
+
+        self.api_session = RelativeSession(self.doc.absolute_url())
+        self.api_session.headers.update({'Accept': 'application/json'})
+        self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD)
+
+        transaction.commit()
+
+    def test_lock_object(self):
+        response = self.api_session.post('/@lock',)
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(ILockable(self.doc).locked())
+
+    def test_lock_object_non_stealable(self):
+        response = self.api_session.post('/@lock', json={'stealable': False})
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(INonStealableLock.providedBy(self.doc))
+
+    def test_lock_object_with_custom_timeout(self):
+        response = self.api_session.post('/@lock', json={'timeout': 86400})
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(self.doc.wl_lockValues()[0].getTimeout(), 86400)
+
+    def test_unlock_object(self):
+        lockable = ILockable(self.doc)
+        lockable.lock()
+        transaction.commit()
+        response = self.api_session.post('/@unlock')
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 200)
+        self.assertFalse(lockable.locked())
+
+    def test_refresh_lock(self):
+        lockable = ILockable(self.doc)
+        lockable.lock()
+        modified = self.doc.wl_lockValues()[0].getModifiedTime()
+        transaction.commit()
+        response = self.api_session.post('/@refresh-lock')
+        transaction.commit()
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(
+            self.doc.wl_lockValues()[0].getModifiedTime() > modified)
+
+    def test_lock_info_for_locked_object(self):
+        lockable = ILockable(self.doc)
+        lockable.lock()
+        transaction.commit()
+        response = self.api_session.get('/@lock')
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(response.json()['locked'])
+
+    def test_lock_info_for_unlocked_object(self):
+        response = self.api_session.get('/@lock')
+
+        self.assertEqual(response.status_code, 200)
+        self.assertFalse(response.json()['locked'])
+
+    def test_update_locked_object_without_token_fails(self):
+        lockable = ILockable(self.doc)
+        lockable.lock()
+        transaction.commit()
+        response = self.api_session.patch('/', json={'title': 'New Title'})
+        transaction.commit()
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(self.doc.Title(), 'My Document')
+
+    def test_update_locked_object_with_token_succeeds(self):
+        lockable = ILockable(self.doc)
+        lockable.lock()
+        transaction.commit()
+        response = self.api_session.patch(
+            '/',
+            headers={'Lock-Token': lockable.lock_info()[0]['token']},
+            json={'title': 'New Title'})
+        transaction.commit()
+        self.assertEqual(response.status_code, 204)
+        self.assertEqual(self.doc.Title(), 'New Title')



------------------------------------------------------------------------------
Check out the vibrant tech community on one of the world's most
engaging tech sites, Slashdot.org! http://sdm.link/slashdot