comparison test/rest_common.py @ 5630:07abc8d36940

Add etag support to rest interface to prevent multiple users from overwriting other users changes. All GET requests for an object (issue, user, keyword etc.) or a property of an object (e.g the title of an issue) return the etag for the object in the ETag header as well as the @etag field in the returned object. All requests that change existing objects (DELETE, PUT or PATCH) require: 1 A request include an ETag header with the etag value retrieved for the object. 2 A submits a form that includes the field @etag that must have the value retrieved for the object. If an etag is not supplied by one of these methods, or any supplied etag does not match the etag calculated at the time the DELETE, PUT or PATCH request is made, HTTP error 412 (Precondition Failed) is returned and no change is made. At that time the client code should retrieve the object again, reconcile the changes and can try to send a new update. The etag is the md5 hash of the representation (repr()) of the object retrieved from the database.
author John Rouillard <rouilj@ieee.org>
date Fri, 01 Mar 2019 22:57:07 -0500
parents 1c4adab65faf
children f576957cbb1f
comparison
equal deleted inserted replaced
5624:b3618882f906 5630:07abc8d36940
3 import shutil 3 import shutil
4 import errno 4 import errno
5 5
6 from roundup.cgi.exceptions import * 6 from roundup.cgi.exceptions import *
7 from roundup import password, hyperdb 7 from roundup import password, hyperdb
8 from roundup.rest import RestfulInstance 8 from roundup.rest import RestfulInstance, calculate_etag
9 from roundup.backends import list_backends 9 from roundup.backends import list_backends
10 from roundup.cgi import client 10 from roundup.cgi import client
11 import random 11 import random
12 12
13 from .db_test_base import setupTracker 13 from .db_test_base import setupTracker
14
15 from .mocknull import MockNull
14 16
15 NEEDS_INSTANCE = 1 17 NEEDS_INSTANCE = 1
16 18
17 19
18 class TestCase(): 20 class TestCase():
60 env = { 62 env = {
61 'PATH_INFO': 'http://localhost/rounduptest/rest/', 63 'PATH_INFO': 'http://localhost/rounduptest/rest/',
62 'HTTP_HOST': 'localhost', 64 'HTTP_HOST': 'localhost',
63 'TRACKER_NAME': 'rounduptest' 65 'TRACKER_NAME': 'rounduptest'
64 } 66 }
65 self.dummy_client = client.Client(self.instance, None, env, [], None) 67 self.dummy_client = client.Client(self.instance, MockNull(), env, [], None)
68 self.dummy_client.request.headers.getheader = self.get_etag_header
66 self.empty_form = cgi.FieldStorage() 69 self.empty_form = cgi.FieldStorage()
67 70
68 self.server = RestfulInstance(self.dummy_client, self.db) 71 self.server = RestfulInstance(self.dummy_client, self.db)
69 72
70 def tearDown(self): 73 def tearDown(self):
72 try: 75 try:
73 shutil.rmtree(self.dirname) 76 shutil.rmtree(self.dirname)
74 except OSError as error: 77 except OSError as error:
75 if error.errno not in (errno.ENOENT, errno.ESRCH): 78 if error.errno not in (errno.ENOENT, errno.ESRCH):
76 raise 79 raise
80
81 def get_etag_header (self, header, not_found=None):
82 try:
83 return self.etag_header
84 except AttributeError:
85 return None
77 86
78 def testGet(self): 87 def testGet(self):
79 """ 88 """
80 Retrieve all three users 89 Retrieve all three users
81 obtain data for 'joe' 90 obtain data for 'joe'
240 ] 249 ]
241 results = self.server.get_collection('issue', form) 250 results = self.server.get_collection('issue', form)
242 self.assertEqual(self.dummy_client.response_code, 200) 251 self.assertEqual(self.dummy_client.response_code, 200)
243 self.assertEqual(len(results['data']), 0) 252 self.assertEqual(len(results['data']), 0)
244 253
254
255 def testEtagProcessing(self):
256 '''
257 Etags can come from two places:
258 ETag http header
259 @etags value posted in the form
260
261 Both will be checked if availble. If either one
262 fails, the etag check will fail.
263
264 Run over header only, etag in form only, both,
265 each one broke and no etag. Use the put command
266 to triger the etag checking code.
267 '''
268 for mode in ('header', 'etag', 'both',
269 'brokenheader', 'brokenetag', 'none'):
270 try:
271 # clean up any old header
272 del(self.etag_header)
273 except AttributeError:
274 pass
275
276 form = cgi.FieldStorage()
277 etag = calculate_etag(self.db.user.getnode(self.joeid))
278 form.list = [
279 cgi.MiniFieldStorage('data', 'Joe Doe Doe'),
280 ]
281
282 if mode == 'header':
283 print "Mode = %s"%mode
284 self.etag_header = etag
285 elif mode == 'etag':
286 print "Mode = %s"%mode
287 form.list.append(cgi.MiniFieldStorage('@etag', etag))
288 elif mode == 'both':
289 print "Mode = %s"%mode
290 self.etag_header = etag
291 form.list.append(cgi.MiniFieldStorage('@etag', etag))
292 elif mode == 'brokenheader':
293 print "Mode = %s"%mode
294 self.etag_header = 'bad'
295 form.list.append(cgi.MiniFieldStorage('@etag', etag))
296 elif mode == 'brokenetag':
297 print "Mode = %s"%mode
298 self.etag_header = etag
299 form.list.append(cgi.MiniFieldStorage('@etag', 'bad'))
300 elif mode == 'none':
301 print "Mode = %s"%mode
302 else:
303 self.fail("unknown mode found")
304
305 results = self.server.put_attribute(
306 'user', self.joeid, 'realname', form
307 )
308 if mode not in ('brokenheader', 'brokenetag', 'none'):
309 self.assertEqual(self.dummy_client.response_code, 200)
310 else:
311 self.assertEqual(self.dummy_client.response_code, 412)
312
313
245 def testPut(self): 314 def testPut(self):
246 """ 315 """
247 Change joe's 'realname' 316 Change joe's 'realname'
248 Check if we can't change admin's detail 317 Check if we can't change admin's detail
249 """ 318 """
250 # change Joe's realname via attribute uri 319 # fail to change Joe's realname via attribute uri
320 # no etag
251 form = cgi.FieldStorage() 321 form = cgi.FieldStorage()
252 form.list = [ 322 form.list = [
253 cgi.MiniFieldStorage('data', 'Joe Doe Doe') 323 cgi.MiniFieldStorage('data', 'Joe Doe Doe')
254 ] 324 ]
255 results = self.server.put_attribute( 325 results = self.server.put_attribute(
256 'user', self.joeid, 'realname', form 326 'user', self.joeid, 'realname', form
257 ) 327 )
328 self.assertEqual(self.dummy_client.response_code, 412)
258 results = self.server.get_attribute( 329 results = self.server.get_attribute(
259 'user', self.joeid, 'realname', self.empty_form 330 'user', self.joeid, 'realname', self.empty_form
260 ) 331 )
261 self.assertEqual(self.dummy_client.response_code, 200) 332 self.assertEqual(self.dummy_client.response_code, 200)
333 self.assertEqual(results['data']['data'], 'Joe Random')
334
335 # change Joe's realname via attribute uri
336 form = cgi.FieldStorage()
337 etag = calculate_etag(self.db.user.getnode(self.joeid))
338 form.list = [
339 cgi.MiniFieldStorage('data', 'Joe Doe Doe'),
340 ]
341
342 self.etag_header = etag # use etag in header
343 results = self.server.put_attribute(
344 'user', self.joeid, 'realname', form
345 )
346 self.assertEqual(self.dummy_client.response_code, 200)
347 results = self.server.get_attribute(
348 'user', self.joeid, 'realname', self.empty_form
349 )
350 self.assertEqual(self.dummy_client.response_code, 200)
262 self.assertEqual(results['data']['data'], 'Joe Doe Doe') 351 self.assertEqual(results['data']['data'], 'Joe Doe Doe')
352 del(self.etag_header)
263 353
264 # Reset joe's 'realname'. 354 # Reset joe's 'realname'.
265 form = cgi.FieldStorage() 355 form = cgi.FieldStorage()
266 form.list = [ 356 etag = calculate_etag(self.db.user.getnode(self.joeid))
267 cgi.MiniFieldStorage('realname', 'Joe Doe') 357 form.list = [
358 cgi.MiniFieldStorage('realname', 'Joe Doe'),
359 cgi.MiniFieldStorage('@etag', etag)
268 ] 360 ]
269 results = self.server.put_element('user', self.joeid, form) 361 results = self.server.put_element('user', self.joeid, form)
362 self.assertEqual(self.dummy_client.response_code, 200)
270 results = self.server.get_element('user', self.joeid, self.empty_form) 363 results = self.server.get_element('user', self.joeid, self.empty_form)
271 self.assertEqual(self.dummy_client.response_code, 200) 364 self.assertEqual(self.dummy_client.response_code, 200)
272 self.assertEqual(results['data']['attributes']['realname'], 'Joe Doe') 365 self.assertEqual(results['data']['attributes']['realname'], 'Joe Doe')
273 366
274 # check we can't change admin's details 367 # check we can't change admin's details
372 Test Delete an attribute 465 Test Delete an attribute
373 """ 466 """
374 # create a new issue with userid 1 in the nosy list 467 # create a new issue with userid 1 in the nosy list
375 issue_id = self.db.issue.create(title='foo', nosy=['1']) 468 issue_id = self.db.issue.create(title='foo', nosy=['1'])
376 469
470 # No etag, so this should return 412 - Precondition Failed
471 # With no changes
472 results = self.server.delete_attribute(
473 'issue', issue_id, 'nosy', self.empty_form
474 )
475 self.assertEqual(self.dummy_client.response_code, 412)
476 results = self.server.get_element('issue', issue_id, self.empty_form)
477 results = results['data']
478 self.assertEqual(self.dummy_client.response_code, 200)
479 self.assertEqual(len(results['attributes']['nosy']), 1)
480 self.assertListEqual(results['attributes']['nosy'], ['1'])
481
482 form = cgi.FieldStorage()
483 etag = calculate_etag(self.db.issue.getnode(issue_id))
484 form.list.append(cgi.MiniFieldStorage('@etag', etag))
377 # remove the title and nosy 485 # remove the title and nosy
378 results = self.server.delete_attribute( 486 results = self.server.delete_attribute(
379 'issue', issue_id, 'title', self.empty_form 487 'issue', issue_id, 'title', form
380 ) 488 )
381 self.assertEqual(self.dummy_client.response_code, 200) 489 self.assertEqual(self.dummy_client.response_code, 200)
382 490
491 del(form.list[-1])
492 etag = calculate_etag(self.db.issue.getnode(issue_id))
493 form.list.append(cgi.MiniFieldStorage('@etag', etag))
383 results = self.server.delete_attribute( 494 results = self.server.delete_attribute(
384 'issue', issue_id, 'nosy', self.empty_form 495 'issue', issue_id, 'nosy', form
385 ) 496 )
386 self.assertEqual(self.dummy_client.response_code, 200) 497 self.assertEqual(self.dummy_client.response_code, 200)
387 498
388 # verify the result 499 # verify the result
389 results = self.server.get_element('issue', issue_id, self.empty_form) 500 results = self.server.get_element('issue', issue_id, self.empty_form)
398 Test Patch op 'Add' 509 Test Patch op 'Add'
399 """ 510 """
400 # create a new issue with userid 1 in the nosy list 511 # create a new issue with userid 1 in the nosy list
401 issue_id = self.db.issue.create(title='foo', nosy=['1']) 512 issue_id = self.db.issue.create(title='foo', nosy=['1'])
402 513
403 # add userid 2 to the nosy list 514 # fail to add userid 2 to the nosy list
515 # no etag
404 form = cgi.FieldStorage() 516 form = cgi.FieldStorage()
405 form.list = [ 517 form.list = [
406 cgi.MiniFieldStorage('op', 'add'), 518 cgi.MiniFieldStorage('op', 'add'),
407 cgi.MiniFieldStorage('nosy', '2') 519 cgi.MiniFieldStorage('nosy', '2')
408 ] 520 ]
409 results = self.server.patch_element('issue', issue_id, form) 521 results = self.server.patch_element('issue', issue_id, form)
522 self.assertEqual(self.dummy_client.response_code, 412)
523
524 etag = calculate_etag(self.db.issue.getnode(issue_id))
525 form = cgi.FieldStorage()
526 form.list = [
527 cgi.MiniFieldStorage('op', 'add'),
528 cgi.MiniFieldStorage('nosy', '2'),
529 cgi.MiniFieldStorage('@etag', etag)
530 ]
531 results = self.server.patch_element('issue', issue_id, form)
410 self.assertEqual(self.dummy_client.response_code, 200) 532 self.assertEqual(self.dummy_client.response_code, 200)
411 533
412 # verify the result 534 # verify the result
413 results = self.server.get_element('issue', issue_id, self.empty_form) 535 results = self.server.get_element('issue', issue_id, self.empty_form)
414 results = results['data'] 536 results = results['data']
421 Test Patch op 'Replace' 543 Test Patch op 'Replace'
422 """ 544 """
423 # create a new issue with userid 1 in the nosy list and status = 1 545 # create a new issue with userid 1 in the nosy list and status = 1
424 issue_id = self.db.issue.create(title='foo', nosy=['1'], status='1') 546 issue_id = self.db.issue.create(title='foo', nosy=['1'], status='1')
425 547
426 # replace userid 2 to the nosy list and status = 3 548 # fail to replace userid 2 to the nosy list and status = 3
549 # no etag.
427 form = cgi.FieldStorage() 550 form = cgi.FieldStorage()
428 form.list = [ 551 form.list = [
429 cgi.MiniFieldStorage('op', 'replace'), 552 cgi.MiniFieldStorage('op', 'replace'),
430 cgi.MiniFieldStorage('nosy', '2'), 553 cgi.MiniFieldStorage('nosy', '2'),
431 cgi.MiniFieldStorage('status', '3') 554 cgi.MiniFieldStorage('status', '3')
432 ] 555 ]
433 results = self.server.patch_element('issue', issue_id, form) 556 results = self.server.patch_element('issue', issue_id, form)
434 self.assertEqual(self.dummy_client.response_code, 200) 557 self.assertEqual(self.dummy_client.response_code, 412)
435 558 results = self.server.get_element('issue', issue_id, self.empty_form)
559 results = results['data']
560 self.assertEqual(self.dummy_client.response_code, 200)
561 self.assertEqual(results['attributes']['status'], '1')
562 self.assertEqual(len(results['attributes']['nosy']), 1)
563 self.assertListEqual(results['attributes']['nosy'], ['1'])
564
565 # replace userid 2 to the nosy list and status = 3
566 etag = calculate_etag(self.db.issue.getnode(issue_id))
567 form = cgi.FieldStorage()
568 form.list = [
569 cgi.MiniFieldStorage('op', 'replace'),
570 cgi.MiniFieldStorage('nosy', '2'),
571 cgi.MiniFieldStorage('status', '3'),
572 cgi.MiniFieldStorage('@etag', etag)
573 ]
574 results = self.server.patch_element('issue', issue_id, form)
575 self.assertEqual(self.dummy_client.response_code, 200)
436 # verify the result 576 # verify the result
437 results = self.server.get_element('issue', issue_id, self.empty_form) 577 results = self.server.get_element('issue', issue_id, self.empty_form)
438 results = results['data'] 578 results = results['data']
439 self.assertEqual(self.dummy_client.response_code, 200) 579 self.assertEqual(self.dummy_client.response_code, 200)
440 self.assertEqual(results['attributes']['status'], '3') 580 self.assertEqual(results['attributes']['status'], '3')
446 Test Patch Action 'Remove' 586 Test Patch Action 'Remove'
447 """ 587 """
448 # create a new issue with userid 1 and 2 in the nosy list 588 # create a new issue with userid 1 and 2 in the nosy list
449 issue_id = self.db.issue.create(title='foo', nosy=['1', '2']) 589 issue_id = self.db.issue.create(title='foo', nosy=['1', '2'])
450 590
451 # remove the nosy list and the title 591 # fail to remove the nosy list and the title
592 # no etag
452 form = cgi.FieldStorage() 593 form = cgi.FieldStorage()
453 form.list = [ 594 form.list = [
454 cgi.MiniFieldStorage('op', 'remove'), 595 cgi.MiniFieldStorage('op', 'remove'),
455 cgi.MiniFieldStorage('nosy', ''), 596 cgi.MiniFieldStorage('nosy', ''),
456 cgi.MiniFieldStorage('title', '') 597 cgi.MiniFieldStorage('title', '')
457 ] 598 ]
458 results = self.server.patch_element('issue', issue_id, form) 599 results = self.server.patch_element('issue', issue_id, form)
600 self.assertEqual(self.dummy_client.response_code, 412)
601 results = self.server.get_element('issue', issue_id, self.empty_form)
602 results = results['data']
603 self.assertEqual(self.dummy_client.response_code, 200)
604 self.assertEqual(results['attributes']['title'], 'foo')
605 self.assertEqual(len(results['attributes']['nosy']), 2)
606 self.assertEqual(results['attributes']['nosy'], ['1', '2'])
607
608 # remove the nosy list and the title
609 form = cgi.FieldStorage()
610 etag = calculate_etag(self.db.issue.getnode(issue_id))
611 form.list = [
612 cgi.MiniFieldStorage('op', 'remove'),
613 cgi.MiniFieldStorage('nosy', ''),
614 cgi.MiniFieldStorage('title', ''),
615 cgi.MiniFieldStorage('@etag', etag)
616 ]
617 results = self.server.patch_element('issue', issue_id, form)
459 self.assertEqual(self.dummy_client.response_code, 200) 618 self.assertEqual(self.dummy_client.response_code, 200)
460 619
461 # verify the result 620 # verify the result
462 results = self.server.get_element('issue', issue_id, self.empty_form) 621 results = self.server.get_element('issue', issue_id, self.empty_form)
463 results = results['data'] 622 results = results['data']
471 Test Patch Action 'Action' 630 Test Patch Action 'Action'
472 """ 631 """
473 # create a new issue with userid 1 and 2 in the nosy list 632 # create a new issue with userid 1 and 2 in the nosy list
474 issue_id = self.db.issue.create(title='foo') 633 issue_id = self.db.issue.create(title='foo')
475 634
476 # execute action retire 635 # fail to execute action retire
636 # no etag
477 form = cgi.FieldStorage() 637 form = cgi.FieldStorage()
478 form.list = [ 638 form.list = [
479 cgi.MiniFieldStorage('op', 'action'), 639 cgi.MiniFieldStorage('op', 'action'),
480 cgi.MiniFieldStorage('action_name', 'retire') 640 cgi.MiniFieldStorage('action_name', 'retire')
481 ] 641 ]
482 results = self.server.patch_element('issue', issue_id, form) 642 results = self.server.patch_element('issue', issue_id, form)
643 self.assertEqual(self.dummy_client.response_code, 412)
644 self.assertFalse(self.db.issue.is_retired(issue_id))
645
646 # execute action retire
647 form = cgi.FieldStorage()
648 etag = calculate_etag(self.db.issue.getnode(issue_id))
649 form.list = [
650 cgi.MiniFieldStorage('op', 'action'),
651 cgi.MiniFieldStorage('action_name', 'retire'),
652 cgi.MiniFieldStorage('@etag', etag)
653 ]
654 results = self.server.patch_element('issue', issue_id, form)
483 self.assertEqual(self.dummy_client.response_code, 200) 655 self.assertEqual(self.dummy_client.response_code, 200)
484 656
485 # verify the result 657 # verify the result
486 self.assertTrue(self.db.issue.is_retired(issue_id)) 658 self.assertTrue(self.db.issue.is_retired(issue_id))
487 659
490 Test Patch Action 'Remove' only some element from a list 662 Test Patch Action 'Remove' only some element from a list
491 """ 663 """
492 # create a new issue with userid 1, 2, 3 in the nosy list 664 # create a new issue with userid 1, 2, 3 in the nosy list
493 issue_id = self.db.issue.create(title='foo', nosy=['1', '2', '3']) 665 issue_id = self.db.issue.create(title='foo', nosy=['1', '2', '3'])
494 666
495 # remove the nosy list and the title 667 # fail to remove the nosy list and the title
668 # no etag
496 form = cgi.FieldStorage() 669 form = cgi.FieldStorage()
497 form.list = [ 670 form.list = [
498 cgi.MiniFieldStorage('op', 'remove'), 671 cgi.MiniFieldStorage('op', 'remove'),
499 cgi.MiniFieldStorage('nosy', '1, 2'), 672 cgi.MiniFieldStorage('nosy', '1, 2'),
673 ]
674 results = self.server.patch_element('issue', issue_id, form)
675 self.assertEqual(self.dummy_client.response_code, 412)
676 results = self.server.get_element('issue', issue_id, self.empty_form)
677 results = results['data']
678 self.assertEqual(self.dummy_client.response_code, 200)
679 self.assertEqual(len(results['attributes']['nosy']), 3)
680 self.assertEqual(results['attributes']['nosy'], ['1', '2', '3'])
681
682 # remove the nosy list and the title
683 form = cgi.FieldStorage()
684 etag = calculate_etag(self.db.issue.getnode(issue_id))
685 form.list = [
686 cgi.MiniFieldStorage('op', 'remove'),
687 cgi.MiniFieldStorage('nosy', '1, 2'),
688 cgi.MiniFieldStorage('@etag', etag)
500 ] 689 ]
501 results = self.server.patch_element('issue', issue_id, form) 690 results = self.server.patch_element('issue', issue_id, form)
502 self.assertEqual(self.dummy_client.response_code, 200) 691 self.assertEqual(self.dummy_client.response_code, 200)
503 692
504 # verify the result 693 # verify the result

Roundup Issue Tracker: http://roundup-tracker.org/