Merge lp:~rvb/maas/bug-1123986-api-fix into lp:~maas-committers/maas/trunk

Proposed by Raphaël Badin
Status: Merged
Approved by: Raphaël Badin
Approved revision: no longer in the source branch.
Merged at revision: 1441
Proposed branch: lp:~rvb/maas/bug-1123986-api-fix
Merge into: lp:~maas-committers/maas/trunk
Prerequisite: lp:~rvb/maas/bug-1123986-db-filestorage-key
Diff against target: 462 lines (+200/-61)
5 files modified
src/maasserver/api.py (+37/-19)
src/maasserver/models/filestorage.py (+4/-4)
src/maasserver/testing/factory.py (+2/-2)
src/maasserver/tests/test_api.py (+152/-33)
src/maasserver/tests/test_filestorage.py (+5/-3)
To merge this branch: bzr merge lp:~rvb/maas/bug-1123986-api-fix
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+149059@code.launchpad.net

Commit message

Adapt the file storage API to the 'owner' field on FileStorage objects.

Description of the change

This branch adapts the file storage API to the new 'owner' field.
Each user is now virtually isolated in his own namespace when uploading/reading file from the file storage API.
Since we have to cope with existing installations there are a few caveats:
- when a user gets a file from /files/<filename>/, if no owned-file can be found, the API will try to return a non-owned file with the same name
- the anonymous API will always return the *last* uploaded file with the given name.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Looks good, but I have a couple of questions.

[1]

-def get_file(handler, request):
+def get_file_by_name(handler, request):
     """Get a named file from the file storage.

     :param filename: The exact name of the file you want to get.
     :type filename: string
     :return: The file is returned in the response content.
     """
-    filename = request.GET.get("filename", None)
-    if not filename:
-        raise MAASAPIBadRequest("Filename not supplied")
+    filename = get_mandatory_param(request.GET, 'filename')
     try:
-        db_file = FileStorage.objects.get(filename=filename)
+        db_file = FileStorage.objects.filter(filename=filename).latest('id')

Shouldn't this restrict itself to files without an owner?

[2]

     def delete(self, request, filename):
         """Delete a FileStorage object."""
-        stored_file = get_object_or_404(FileStorage, filename=filename)
+        stored_file = get_object_or_404(
+            FileStorage, filename=filename, owner=request.user)
         stored_file.delete()

Should this fall back to deleting files without owner when an
owned file is not found?

review: Needs Information
Revision history for this message
Raphaël Badin (rvb) wrote :

> Looks good, but I have a couple of questions.
>
>
> [1]
>
> -def get_file(handler, request):
> +def get_file_by_name(handler, request):
>     """Get a named file from the file storage.
>
>     :param filename: The exact name of the file you want to get.
>     :type filename: string
>     :return: The file is returned in the response content.
>     """
> -    filename = request.GET.get("filename", None)
> -    if not filename:
> -        raise MAASAPIBadRequest("Filename not supplied")
> +    filename = get_mandatory_param(request.GET, 'filename')
>     try:
> -        db_file = FileStorage.objects.get(filename=filename)
> +        db_file = FileStorage.objects.filter(filename=filename).latest('id')
>
> Shouldn't this restrict itself to files without an owner?

No, imagine you upgrade your juju client in an existing env with services deployed: next time the juju client will upload a file (the file will be storage with an owner), the node might want to read it with the URL it has stored.

> [2]
>
>     def delete(self, request, filename):
>         """Delete a FileStorage object."""
> -        stored_file = get_object_or_404(FileStorage, filename=filename)
> +        stored_file = get_object_or_404(
> +            FileStorage, filename=filename, owner=request.user)
>         stored_file.delete()
>
> Should this fall back to deleting files without owner when an
> owned file is not found?

The python provider does not use this. So the only provider which will use this is the Go provider, which will always set an owner.

Revision history for this message
Raphaël Badin (rvb) wrote :

> [2]
>
>     def delete(self, request, filename):
>         """Delete a FileStorage object."""
> -        stored_file = get_object_or_404(FileStorage, filename=filename)
> +        stored_file = get_object_or_404(
> +            FileStorage, filename=filename, owner=request.user)
>         stored_file.delete()
>
> Should this fall back to deleting files without owner when an
> owned file is not found?

The response I made above is still valid but I think we've got a choice to make here:
a) either we focus on supporting the old provider: in this case, as I said, the version of delete is all right because the provider does not use it. But this means we also can remove the compatibility layer I added in the handler of "GET /api/1.0/files/<filename>/" because that handler too is not used by the provider (it was added very recently actually).
b) or we try to have a general behavior that is coherent when upgrading (independently from what the provider uses). In this case, as you pointed out, we need to change delete() to delete files without an owner.

I'm tempted to go for a) because:
- the filestorage stuff is really just here to be used by Juju
- adding a compatibility layer adds yet another layer of complexity to the code

Revision history for this message
Gavin Panella (allenap) wrote :

> > Shouldn't this restrict itself to files without an owner?
>
> No, imagine you upgrade your juju client in an existing env with services
> deployed: next time the juju client will upload a file (the file will be
> storage with an owner), the node might want to read it with the URL it has
> stored.

But might this code get the file that belongs to another user? I'm
thinking it should look for a file either owned by the user *or*
unowned. I suspect I'm not seeing something in the implementation,
which could indicate that it could be structured better, or that I'm
stupid :)

> > Should this fall back to deleting files without owner when an
> > owned file is not found?
>
> The python provider does not use this.  So the only provider which will use
> this is the Go provider, which will always set an owner.

The API ought to have a way to delete unowned files, even if neither
Juju provider needs it.

Revision history for this message
Raphaël Badin (rvb) wrote :

> But might this code get the file that belongs to another user?

Yes, it will definitely do that if you're still using an old version of juju … and that's exactly the situation we have now already (basically the goal is to: make the situation not worse if you use an old juju, fix the situation if you use a new juju… I'm afraid that's the best we can do)

Revision history for this message
Raphaël Badin (rvb) wrote :

> The API ought to have a way to delete unowned files, even if neither
> Juju provider needs it.

Just like we have a consensus on this, done.

Revision history for this message
Raphaël Badin (rvb) wrote :

This is now all fixed. I get rid of the whole 'values' non-sense in the follow-up branch, don't worry.

Revision history for this message
Gavin Panella (allenap) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api.py'
2--- src/maasserver/api.py 2013-02-14 14:25:39 +0000
3+++ src/maasserver/api.py 2013-02-22 09:28:21 +0000
4@@ -777,18 +777,16 @@
5 return ('node_mac_handler', [node_system_id, mac_address])
6
7
8-def get_file(handler, request):
9+def get_file_by_name(handler, request):
10 """Get a named file from the file storage.
11
12 :param filename: The exact name of the file you want to get.
13 :type filename: string
14 :return: The file is returned in the response content.
15 """
16- filename = request.GET.get("filename", None)
17- if not filename:
18- raise MAASAPIBadRequest("Filename not supplied")
19+ filename = get_mandatory_param(request.GET, 'filename')
20 try:
21- db_file = FileStorage.objects.get(filename=filename)
22+ db_file = FileStorage.objects.filter(filename=filename).latest('id')
23 except FileStorage.DoesNotExist:
24 raise MAASAPINotFound("File not found")
25 return HttpResponse(db_file.content, status=httplib.OK)
26@@ -808,7 +806,7 @@
27 """
28 create = read = update = delete = None
29
30- get = operation(idempotent=True, exported_as='get')(get_file)
31+ get = operation(idempotent=True, exported_as='get')(get_file_by_name)
32
33 @classmethod
34 def resource_uri(cls, *args, **kwargs):
35@@ -823,10 +821,35 @@
36 DISPLAYED_FILES_FIELDS_OBJECT = DISPLAYED_FILES_FIELDS + ('content', )
37
38
39+def get_owned_file_or_404(filename, user, values=False):
40+ """Return the file named 'filename' owned by 'user'.
41+
42+ If there is no such file, try getting the corresponding unowned file
43+ to be compatible with old versions of MAAS where all the files where
44+ unowned.
45+ """
46+ stored_files = FileStorage.objects.filter(
47+ filename=filename, owner=user)
48+ # filename and owner are 'unique together', we can have either 0 or 1
49+ # objects in 'stored_files'.
50+ if len(stored_files) == 0:
51+ # In order to support upgrading from installations where the notion
52+ # of a file owner was not yet implemented, return non-owned files
53+ # with this filename at a last resort option.
54+ stored_files = FileStorage.objects.filter(
55+ filename=filename, owner=None)
56+ if len(stored_files) == 0:
57+ raise Http404
58+ if values:
59+ return stored_files.values()[0]
60+ else:
61+ return stored_files[0]
62+
63+
64 class FileHandler(OperationsHandler):
65 """Manage a FileStorage object.
66
67- The file is identified by its filename.
68+ The file is identified by its filename and owner.
69 """
70 model = FileStorage
71 fields = DISPLAYED_FILES_FIELDS
72@@ -836,14 +859,8 @@
73 """GET a FileStorage object as a json object.
74
75 The 'content' of the file is base64-encoded."""
76- # 'content' is a BinaryField, its 'value' is a base64-encoded version
77- # of its value.
78- stored_files = FileStorage.objects.filter(filename=filename).values()
79- # filename is a 'unique' field, we can have either 0 or 1 objects in
80- # 'stored_files'.
81- if len(stored_files) == 0:
82- raise Http404
83- stored_file = stored_files[0]
84+ stored_file = get_owned_file_or_404(
85+ filename=filename, user=request.user, values=True)
86 # Emit the json for this object manually because, no matter what the
87 # piston documentation says, once an type is associated with a list
88 # of fields by piston's typemapper mechanism, there is no way to
89@@ -858,7 +875,8 @@
90 @operation(idempotent=False)
91 def delete(self, request, filename):
92 """Delete a FileStorage object."""
93- stored_file = get_object_or_404(FileStorage, filename=filename)
94+ stored_file = get_owned_file_or_404(
95+ filename=filename, user=request.user)
96 stored_file.delete()
97 return rc.DELETED
98
99@@ -875,7 +893,7 @@
100 create = read = update = delete = None
101 anonymous = AnonFilesHandler
102
103- get = operation(idempotent=True, exported_as='get')(get_file)
104+ get = operation(idempotent=True, exported_as='get')(get_file_by_name)
105
106 @operation(idempotent=False)
107 def add(self, request):
108@@ -899,7 +917,7 @@
109 # As per the comment in FileStorage, this ought to deal in
110 # chunks instead of reading the file into memory, but large
111 # files are not expected.
112- FileStorage.objects.save_file(filename, uploaded_file)
113+ FileStorage.objects.save_file(filename, uploaded_file, request.user)
114 return HttpResponse('', status=httplib.CREATED)
115
116 @operation(idempotent=True)
117@@ -913,7 +931,7 @@
118 :type prefix: string
119 """
120 prefix = request.GET.get("prefix", None)
121- files = FileStorage.objects.all()
122+ files = FileStorage.objects.filter(owner=request.user)
123 if prefix is not None:
124 files = files.filter(filename__startswith=prefix)
125 files = files.order_by('filename')
126
127=== modified file 'src/maasserver/models/filestorage.py'
128--- src/maasserver/models/filestorage.py 2013-02-22 09:28:21 +0000
129+++ src/maasserver/models/filestorage.py 2013-02-22 09:28:21 +0000
130@@ -47,17 +47,17 @@
131 old file will continue without iterruption.
132 """
133
134- def save_file(self, filename, file_object):
135+ def save_file(self, filename, file_object, owner):
136 """Save the file to the database.
137
138- If a file of that name already existed, it will be replaced by the
139- new contents.
140+ If a file of that name/owner already existed, it will be replaced by
141+ the new contents.
142 """
143 # This probably ought to read in chunks but large files are
144 # not expected.
145 content = Bin(file_object.read())
146 storage, created = self.get_or_create(
147- filename=filename, defaults={'content': content})
148+ filename=filename, owner=owner, defaults={'content': content})
149 if not created:
150 storage.content = content
151 storage.save()
152
153=== modified file 'src/maasserver/testing/factory.py'
154--- src/maasserver/testing/factory.py 2012-12-19 12:31:11 +0000
155+++ src/maasserver/testing/factory.py 2013-02-22 09:28:21 +0000
156@@ -330,9 +330,9 @@
157 admin.save()
158 return admin
159
160- def make_file_storage(self, filename=None, content=None):
161+ def make_file_storage(self, filename=None, content=None, owner=None):
162 fake_file = self.make_file_upload(filename, content)
163- return FileStorage.objects.save_file(fake_file.name, fake_file)
164+ return FileStorage.objects.save_file(fake_file.name, fake_file, owner)
165
166 def make_oauth_header(self, **kwargs):
167 """Fake an OAuth authorization header.
168
169=== modified file 'src/maasserver/tests/test_api.py'
170--- src/maasserver/tests/test_api.py 2013-02-14 12:42:30 +0000
171+++ src/maasserver/tests/test_api.py 2013-02-22 09:28:21 +0000
172@@ -46,7 +46,10 @@
173 reverse,
174 get_script_prefix,
175 )
176-from django.http import QueryDict
177+from django.http import (
178+ Http404,
179+ QueryDict,
180+ )
181 from django.test.client import RequestFactory
182 from django.utils.http import urlquote_plus
183 from fixtures import (
184@@ -62,6 +65,7 @@
185 DISPLAYED_NODEGROUP_FIELDS,
186 extract_constraints,
187 find_nodegroup_for_pxeconfig_request,
188+ get_owned_file_or_404,
189 store_node_power_parameters,
190 )
191 from maasserver.enum import (
192@@ -2621,37 +2625,82 @@
193 return self.client.get(self.get_uri('files/'), params)
194
195
196+class CompatibilityUtilitiesTest(TestCase):
197+
198+ def test_get_owned_file_or_404_returns_owned_file(self):
199+ user = factory.make_user()
200+ filename = factory.make_name('filename')
201+ factory.make_file_storage(filename=filename, owner=None)
202+ filestorage = factory.make_file_storage(filename=filename, owner=user)
203+
204+ self.assertEqual(filestorage, get_owned_file_or_404(filename, user))
205+
206+ def test_get_owned_file_or_404_fallsback_to_nonowned_file(self):
207+ user = factory.make_user()
208+ filename = factory.make_name('filename')
209+ factory.make_file_storage(
210+ filename=factory.make_name('filename'), owner=user)
211+ filestorage = factory.make_file_storage(filename=filename, owner=None)
212+
213+ self.assertEqual(filestorage, get_owned_file_or_404(filename, user))
214+
215+ def test_get_owned_file_or_404_raises_404_if_no_file(self):
216+ user = factory.make_user()
217+ filename = factory.make_name('filename')
218+ factory.make_file_storage(
219+ filename=factory.make_name('filename'), owner=user)
220+ factory.make_file_storage(
221+ filename=filename, owner=factory.make_user())
222+
223+ self.assertRaises(Http404, get_owned_file_or_404, filename, user)
224+
225+ def test_get_owned_file_or_404_returns_values(self):
226+ user = factory.make_user()
227+ filename = factory.make_name('filename')
228+ factory.make_file_storage(filename=filename, owner=None)
229+ factory.make_file_storage(filename=filename, owner=user)
230+
231+ values = FileStorage.objects.filter(
232+ filename=filename, owner=user).values()[0]
233+ self.assertEqual(
234+ values, get_owned_file_or_404(filename, user, values=True))
235+
236+
237 class AnonymousFileStorageAPITest(FileStorageAPITestMixin, AnonAPITestCase):
238
239 def test_get_works_anonymously(self):
240- factory.make_file_storage(
241- filename="foofilers", content=b"give me rope")
242- response = self.make_API_GET_request("get", "foofilers")
243-
244- self.assertEqual(httplib.OK, response.status_code)
245- self.assertEqual(b"give me rope", response.content)
246+ storage = factory.make_file_storage()
247+ response = self.make_API_GET_request("get", storage.filename)
248+
249+ self.assertEqual(httplib.OK, response.status_code)
250+ self.assertEqual(storage.content, response.content)
251+
252+ def test_get_fetches_the_most_recent_file(self):
253+ filename = factory.make_name('file')
254+ factory.make_file_storage(filename=filename, owner=factory.make_user())
255+ storage = factory.make_file_storage(
256+ filename=filename, owner=factory.make_user())
257+ response = self.make_API_GET_request("get", filename)
258+
259+ self.assertEqual(httplib.OK, response.status_code)
260+ self.assertEqual(storage.content, response.content)
261
262 def test_anon_cannot_list_files(self):
263- factory.make_file_storage(
264- filename="filename", content=b"test content")
265+ factory.make_file_storage()
266 response = self.make_API_GET_request("list")
267 # The 'list' operation is not available to anon users.
268 self.assertEqual(httplib.BAD_REQUEST, response.status_code)
269
270 def test_anon_cannot_get_file(self):
271- filename = factory.make_name("file")
272- factory.make_file_storage(
273- filename=filename, content=b"test file content")
274+ storage = factory.make_file_storage()
275 response = self.client.get(
276- reverse('file_handler', args=[filename]))
277+ reverse('file_handler', args=[storage.filename]))
278 self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
279
280 def test_anon_cannot_delete_file(self):
281- filename = factory.make_name('file')
282- factory.make_file_storage(
283- filename=filename, content=b"test content")
284+ storage = factory.make_file_storage()
285 response = self.client.delete(
286- reverse('file_handler', args=[filename]))
287+ reverse('file_handler', args=[storage.filename]))
288 self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
289
290
291@@ -2721,7 +2770,7 @@
292
293 self.assertEqual(httplib.BAD_REQUEST, response.status_code)
294 self.assertIn('text/plain', response['Content-Type'])
295- self.assertEqual("Filename not supplied", response.content)
296+ self.assertEqual("No provided filename!", response.content)
297
298 def test_get_file_fails_with_missing_file(self):
299 response = self.make_API_GET_request("get", filename="missingfilename")
300@@ -2734,19 +2783,28 @@
301 filenames = ["myfiles/a", "myfiles/z", "myfiles/b"]
302 for filename in filenames:
303 factory.make_file_storage(
304- filename=filename, content=b"test content")
305+ filename=filename, content=b"test content",
306+ owner=self.logged_in_user)
307 response = self.make_API_GET_request("list")
308 self.assertEqual(httplib.OK, response.status_code)
309 parsed_results = json.loads(response.content)
310 filenames = [result['filename'] for result in parsed_results]
311 self.assertEqual(sorted(filenames), filenames)
312
313+ def test_list_files_filters_by_owner(self):
314+ factory.make_file_storage(owner=factory.make_user())
315+ response = self.make_API_GET_request("list")
316+ self.assertEqual(httplib.OK, response.status_code)
317+ parsed_results = json.loads(response.content)
318+ self.assertEqual([], parsed_results)
319+
320 def test_list_files_lists_files_with_prefix(self):
321 filenames_with_prefix = ["prefix-file1", "prefix-file2"]
322 filenames = filenames_with_prefix + ["otherfile", "otherfile2"]
323 for filename in filenames:
324 factory.make_file_storage(
325- filename=filename, content=b"test content")
326+ filename=filename, content=b"test content",
327+ owner=self.logged_in_user)
328 response = self.client.get(
329 self.get_uri('files/'), {"op": "list", "prefix": "prefix-"})
330 self.assertEqual(httplib.OK, response.status_code)
331@@ -2756,7 +2814,8 @@
332
333 def test_list_files_does_not_include_file_content(self):
334 factory.make_file_storage(
335- filename="filename", content=b"test content")
336+ filename="filename", content=b"test content",
337+ owner=self.logged_in_user)
338 response = self.make_API_GET_request("list")
339 parsed_results = json.loads(response.content)
340 self.assertNotIn('content', parsed_results[0].keys())
341@@ -2764,7 +2823,8 @@
342 def test_files_resource_uri_are_url_escaped(self):
343 filename = "&*!c/%//filename/"
344 factory.make_file_storage(
345- filename=filename, content=b"test content")
346+ filename=filename, content=b"test content",
347+ owner=self.logged_in_user)
348 response = self.make_API_GET_request("list")
349 parsed_results = json.loads(response.content)
350 resource_uri = parsed_results[0]['resource_uri']
351@@ -2775,21 +2835,80 @@
352 def test_get_file_returns_file_object_with_content_base64_encoded(self):
353 filename = factory.make_name("file")
354 content = sample_binary_data
355- factory.make_file_storage(filename=filename, content=content)
356- response = self.client.get(
357- reverse('file_handler', args=[filename]))
358- parsed_result = json.loads(response.content)
359- self.assertEqual(
360- (filename, content),
361- (
362- parsed_result['filename'],
363- b64decode(parsed_result['content'])
364- ))
365+ factory.make_file_storage(
366+ filename=filename, content=content, owner=self.logged_in_user)
367+ response = self.client.get(
368+ reverse('file_handler', args=[filename]))
369+ parsed_result = json.loads(response.content)
370+ self.assertEqual(
371+ (filename, content),
372+ (
373+ parsed_result['filename'],
374+ b64decode(parsed_result['content'])
375+ ))
376+
377+ def test_get_file_returns_owned_file(self):
378+ # If both an owned file and a non-owned file are present (with the
379+ # same name), the owned file is returned.
380+ filename = factory.make_name("file")
381+ factory.make_file_storage(filename=filename, owner=None)
382+ content = sample_binary_data
383+ factory.make_file_storage(
384+ filename=filename, content=content, owner=self.logged_in_user)
385+ response = self.client.get(
386+ reverse('file_handler', args=[filename]))
387+ parsed_result = json.loads(response.content)
388+ self.assertEqual(
389+ (filename, content),
390+ (
391+ parsed_result['filename'],
392+ b64decode(parsed_result['content'])
393+ ))
394+
395+ def test_get_file_returns_file_object_with_anon_owner(self):
396+ # To be compatible with the MAAS provider present in Juju as of
397+ # revision 616 (lp:juju), reading a file returns the non-owned files
398+ # if no owned file can be found.
399+ filename = factory.make_name("file")
400+ content = sample_binary_data
401+ factory.make_file_storage(
402+ filename=filename, content=content, owner=None)
403+ response = self.client.get(
404+ reverse('file_handler', args=[filename]))
405+ parsed_result = json.loads(response.content)
406+ self.assertEqual(
407+ (filename, content),
408+ (
409+ parsed_result['filename'],
410+ b64decode(parsed_result['content'])
411+ ))
412+
413+ def test_delete_filters_by_owner(self):
414+ storage = factory.make_file_storage(owner=factory.make_user())
415+ response = self.client.delete(
416+ reverse('file_handler', args=[storage.filename]))
417+ self.assertEqual(httplib.NOT_FOUND, response.status_code)
418+ files = FileStorage.objects.filter(filename=storage.filename)
419+ self.assertEqual([storage], list(files))
420
421 def test_delete_file_deletes_file(self):
422 filename = factory.make_name('file')
423 factory.make_file_storage(
424- filename=filename, content=b"test content")
425+ filename=filename, content=b"test content",
426+ owner=self.logged_in_user)
427+ response = self.client.delete(
428+ reverse('file_handler', args=[filename]))
429+ self.assertEqual(httplib.NO_CONTENT, response.status_code)
430+ files = FileStorage.objects.filter(filename=filename)
431+ self.assertEqual([], list(files))
432+
433+ def test_delete_file_deletes_file_with_anon_owner(self):
434+ # To be compatible with the MAAS provider present in Juju as of
435+ # revision 616 (lp:juju), deleting a file deletes the non-owned file
436+ # if no owned file can be found.
437+ filename = factory.make_name('file')
438+ factory.make_file_storage(
439+ filename=filename, content=b"test content", owner=None)
440 response = self.client.delete(
441 reverse('file_handler', args=[filename]))
442 self.assertEqual(httplib.NO_CONTENT, response.status_code)
443
444=== modified file 'src/maasserver/tests/test_filestorage.py'
445--- src/maasserver/tests/test_filestorage.py 2013-02-22 09:28:21 +0000
446+++ src/maasserver/tests/test_filestorage.py 2013-02-22 09:28:21 +0000
447@@ -40,10 +40,12 @@
448 def test_save_file_creates_storage(self):
449 filename = factory.getRandomString()
450 content = self.make_data()
451- storage = FileStorage.objects.save_file(filename, BytesIO(content))
452+ user = factory.make_user()
453+ storage = FileStorage.objects.save_file(
454+ filename, BytesIO(content), user)
455 self.assertEqual(
456- (filename, content),
457- (storage.filename, storage.content))
458+ (filename, content, user),
459+ (storage.filename, storage.content, storage.owner))
460
461 def test_storage_can_be_retrieved(self):
462 filename = factory.getRandomString()