Repository: Kinto/kinto

Issue:
data lost on object with PUT to replace permissions
```
echo '{"data": {"tata": 42}}' | http PUT :8888/v1/buckets/garde --auth user:pass
HTTP/1.1 201 Created
Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff
Content-Length: 166
Content-Type: application/json; charset=UTF-8
Date: Mon, 30 May 2016 10:04:51 GMT
Etag: "1464602691600"
Last-Modified: Mon, 30 May 2016 10:04:51 GMT
Server: waitress

{
    "data": {
        "id": "garde", 
        "last_modified": 1464602691600, 
        "tata": 42
    }, 
    "permissions": {
        "write": [
            "basicauth:f505765817a6b4ea46278be0620ddedd83b10f71f7695683719fe001cf0871d7"
        ]
    }
}

```

```
echo '{"permissions": {"write": ["bizbiz"]}}' | http PUT :8888/v1/buckets/garde --auth user:pass
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff
Content-Length: 216
Content-Type: application/json; charset=UTF-8
Date: Mon, 30 May 2016 10:05:54 GMT
Etag: "1464602754354"
Last-Modified: Mon, 30 May 2016 10:05:54 GMT
Server: waitress

{
    "data": {
        "id": "garde", 
        "last_modified": 1464602754354
    }, 
    "permissions": {
        "collection:create": [], 
        "group:create": [], 
        "read": [], 
        "write": [
            "bizbiz", 
            "basicauth:f505765817a6b4ea46278be0620ddedd83b10f71f7695683719fe001cf0871d7"
        ]
    }
}
```

Interesting to note that the contrary is not true:

```
echo '{"data": {"tata": 42}}' | http PUT :8888/v1/buckets/garde --auth user:pass
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff
Content-Length: 226
Content-Type: application/json; charset=UTF-8
Date: Mon, 30 May 2016 10:06:00 GMT
Etag: "1464602760183"
Last-Modified: Mon, 30 May 2016 10:06:00 GMT
Server: waitress

{
    "data": {
        "id": "garde", 
        "last_modified": 1464602760183, 
        "tata": 42
    }, 
    "permissions": {
        "collection:create": [], 
        "group:create": [], 
        "read": [], 
        "write": [
            "bizbiz", 
            "basicauth:f505765817a6b4ea46278be0620ddedd83b10f71f7695683719fe001cf0871d7"
        ]
    }
}

```



Discussion:

Resolving patch:
diff --git a/kinto/core/resource/__init__.py b/kinto/core/resource/__init__.py
--- a/kinto/core/resource/__init__.py
+++ b/kinto/core/resource/__init__.py
@@ -303,7 +303,7 @@ def collection_post(self):
             :meth:`kinto.core.resource.UserResource.process_record`
         """
         existing = None
-        new_record = self.request.validated['data']
+        new_record = self.request.validated.get('data', {})
         try:
             id_field = self.model.id_field
             # Since ``id`` does not belong to schema, look up in body.
@@ -413,7 +413,8 @@ def put(self):
             if existing:
                 self._raise_412_if_modified(existing)
 
-        post_record = self.request.validated['data']
+        # If `data` is not provided, use existing record (or empty if creation)
+        post_record = self.request.validated.get('data', existing) or {}
 
         record_id = post_record.setdefault(id_field, self.record_id)
         self._raise_400_if_id_mismatch(record_id, self.record_id)
diff --git a/kinto/core/resource/viewset.py b/kinto/core/resource/viewset.py
--- a/kinto/core/resource/viewset.py
+++ b/kinto/core/resource/viewset.py
@@ -115,14 +115,6 @@ def get_record_schema(self, resource_cls, method):
         else:
             record_mapping = resource_cls.mapping
 
-            try:
-                record_mapping.deserialize({})
-                # Empty data accepted.
-                record_mapping.missing = colander.drop
-                record_mapping.default = {}
-            except colander.Invalid:
-                pass
-
         class PayloadSchema(colander.MappingSchema):
             data = record_mapping
 
@@ -200,7 +192,14 @@ def get_record_schema(self, resource_cls, method):
         if method.lower() not in map(str.lower, self.validate_schema_for):
             return schema
 
-        if method.lower() == 'patch':
+        try:
+            empty_allowed = False
+            schema.deserialize({"data": {}})
+            empty_allowed = True
+        except colander.Invalid:
+            pass
+
+        if empty_allowed or method.lower() == 'patch':
             # Data is optional when patching permissions.
             schema.children[-1].missing = colander.drop
 


Test patch:
diff --git a/kinto/tests/core/resource/test_views.py b/kinto/tests/core/resource/test_views.py
--- a/kinto/tests/core/resource/test_views.py
+++ b/kinto/tests/core/resource/test_views.py
@@ -82,6 +82,40 @@ def test_permissions_are_not_modified_if_not_specified(self):
         resp = self.app.put_json(object_uri, body, headers=self.headers)
         self.assertEqual(resp.json['permissions']['read'], ['group:readers'])
 
+    def test_data_are_not_modified_if_not_specified(self):
+        body = {'data': MINIMALIST_RECORD,
+                'permissions': {'read': ['group:readers']}}
+        resp = self.app.post_json(self.collection_url, body,
+                                  headers=self.headers)
+        object_uri = self.get_item_url(resp.json['data']['id'])
+
+        body.pop('data')
+        # With the current Cornice limitations, it is not possible to define
+        # a validator that makes sure that at least of `data` or `permissions`
+        # is specified in body.
+        # https://github.com/mozilla-services/cornice/pull/330
+        # Currently, only "schemaless" resources can have their permissions
+        # replaced via PUT with specifying the `data`.
+        # resp = self.app.put_json(object_uri, body, headers=self.headers)
+        # self.assertEqual(resp.json['data']['name'],
+        #                  MINIMALIST_RECORD['name'])
+        resp = self.app.put_json(object_uri, body, headers=self.headers,
+                                 status=400)
+
+    def test_data_are_not_modified_if_not_specified_on_schemaless(self):
+        self.add_permission('/spores', 'spore:create')
+        body = {'data': MINIMALIST_RECORD,
+                'permissions': {'read': ['group:readers']}}
+        resp = self.app.post_json('/spores', body,
+                                  headers=self.headers)
+        object_uri = '/spores/' + resp.json['data']['id']
+        self.add_permission(object_uri, 'write')
+
+        body.pop('data')
+        resp = self.app.put_json(object_uri, body, headers=self.headers)
+
+        self.assertEqual(resp.json['data']['name'], MINIMALIST_RECORD['name'])
+
     def test_permissions_can_be_modified_using_patch(self):
         body = {'data': MINIMALIST_RECORD,
                 'permissions': {'read': ['group:readers']}}
diff --git a/kinto/tests/test_views_collections_schema.py b/kinto/tests/test_views_collections_schema.py
--- a/kinto/tests/test_views_collections_schema.py
+++ b/kinto/tests/test_views_collections_schema.py
@@ -147,10 +147,10 @@ def test_validation_error_response_provides_details(self):
         self.assertEqual(resp.json['details'][0]['name'], 'title')
 
     def test_records_of_other_bucket_are_not_impacted(self):
-        self.app.put_json('/buckets/blog', headers=self.headers)
-        self.app.put_json('/buckets/blog/collections/articles',
+        self.app.put_json('/buckets/cms', headers=self.headers)
+        self.app.put_json('/buckets/cms/collections/articles',
                           headers=self.headers)
-        self.app.post_json('/buckets/blog/collections/articles/records',
+        self.app.post_json('/buckets/cms/collections/articles/records',
                            {'data': {'body': '<h1>Without title</h1>'}},
                            headers=self.headers)
 
diff --git a/kinto/tests/test_views_groups.py b/kinto/tests/test_views_groups.py
--- a/kinto/tests/test_views_groups.py
+++ b/kinto/tests/test_views_groups.py
@@ -178,6 +178,14 @@ def test_groups_can_be_created_after_deletion(self):
         self.app.put_json(group_url, MINIMALIST_GROUP,
                           headers=headers, status=201)
 
+    def test_groups_data_is_optional_with_patch(self):
+        valid = {'permissions': {'write': ['github:me']}}
+        self.app.put_json(self.group_url, MINIMALIST_GROUP,
+                          headers=self.headers)
+        self.app.patch_json(self.group_url,
+                            valid,
+                            headers=self.headers)
+
 
 class InvalidGroupTest(BaseWebTest, unittest.TestCase):
 
@@ -187,16 +195,18 @@ def setUp(self):
         super(InvalidGroupTest, self).setUp()
         self.create_bucket('beers')
 
+    def test_groups_data_is_required_with_put(self):
+        invalid = {'permissions': {'write': ['github:me']}}
+        resp = self.app.put_json(self.group_url,
+                                 invalid,
+                                 headers=self.headers,
+                                 status=400)
+        self.assertEqual(resp.json['message'], "data is missing")
+
     def test_groups_must_have_members_attribute(self):
-        invalid = {}
+        invalid = {'data': {'alias': 'admins'}}
         resp = self.app.put_json(self.group_url,
                                  invalid,
                                  headers=self.headers,
                                  status=400)
-        self.assertEqual(resp.json['message'],
-                         "data is missing")
-        self.assertDictEqual(resp.json['details'][0], {
-            "description": "data is missing",
-            "location": "body",
-            "name": "data"
-        })
+        self.assertIn('data.members in body', resp.json['message'])