diff test/test_liveserver.py @ 6638:e1588ae185dc issue2550923_computed_property

merge from default branch. Fix travis.ci so CI builds don't error out
author John Rouillard <rouilj@ieee.org>
date Thu, 21 Apr 2022 16:54:17 -0400
parents 198875530c04
children 6ac3667706be
line wrap: on
line diff
--- a/test/test_liveserver.py	Fri Oct 08 00:37:16 2021 -0400
+++ b/test/test_liveserver.py	Thu Apr 21 16:54:17 2022 -0400
@@ -1,10 +1,12 @@
-import shutil, errno, pytest, json, gzip, os
+import shutil, errno, pytest, json, gzip, os, re
 
 from roundup.anypy.strings import b2s
 from roundup.cgi.wsgi_handler import RequestDispatcher
 from .wsgi_liveserver import LiveServerTestCase
 from . import db_test_base
 
+from wsgiref.validate import validator
+
 try:
     import requests
     skip_requests = lambda func, *args, **kwargs: func
@@ -30,6 +32,10 @@
     skip_zstd = mark_class(pytest.mark.skip(
         reason='Skipping zstd tests: zstd library not available'))
 
+import sys
+
+_py3 = sys.version_info[0] > 2
+
 @skip_requests
 class SimpleTest(LiveServerTestCase):
     # have chicken and egg issue here. Need to encode the base_url
@@ -60,7 +66,7 @@
         # set up mailhost so errors get reported to debuging capture file
         cls.db.config.MAILHOST = "localhost"
         cls.db.config.MAIL_HOST = "localhost"
-        cls.db.config.MAIL_DEBUG = "../mail.log.t"
+        cls.db.config.MAIL_DEBUG = "../_test_tracker_mail.log"
 
         # enable static precompressed files
         cls.db.config.WEB_USE_PRECOMPRESSED_FILES = 1
@@ -84,7 +90,13 @@
 
     def create_app(self):
         '''The wsgi app to start'''
-        return RequestDispatcher(self.dirname)
+        if _py3:
+            return validator(RequestDispatcher(self.dirname))
+        else:
+            # wsgiref/validator.py InputWrapper::readline is broke and
+            # doesn't support the max bytes to read argument.
+            return RequestDispatcher(self.dirname)
+
 
     def test_start_page(self):
         """ simple test that verifies that the server can serve a start page.
@@ -95,6 +107,33 @@
         self.assertTrue(b'Creator' in f.content)
 
 
+    def test_rest_invalid_method_collection(self):
+        # use basic auth for rest endpoint
+        f = requests.put(self.url_base() + '/rest/data/user',
+                             auth=('admin', 'sekrit'),
+                             headers = {'content-type': "",
+                             'x-requested-with': "rest"})
+        print(f.status_code)
+        print(f.headers)
+        print(f.content)
+
+        self.assertEqual(f.status_code, 405)
+        expected = { 'Access-Control-Allow-Origin': '*',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
+                     'Allow': 'DELETE, GET, OPTIONS, POST',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
+        }
+
+        print(f.headers)
+        # use dict comprehension to remove fields like date,
+        # content-length etc. from f.headers.
+        self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)
+
+        content = json.loads(f.content)
+
+        exp_content = "Method PUT not allowed. Allowed: DELETE, GET, OPTIONS, POST"
+        self.assertEqual(exp_content, content['error']['msg'])
+
     def test_http_options(self):
         """ options returns an unimplemented error for this case."""
         
@@ -113,11 +152,10 @@
         print(f.headers)
 
         self.assertEqual(f.status_code, 204)
-        expected = { 'Content-Type': 'application/json',
-                     'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+        expected = { 'Access-Control-Allow-Origin': '*',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
         }
 
         # use dict comprehension to remove fields like date,
@@ -134,11 +172,10 @@
         print(f.headers)
 
         self.assertEqual(f.status_code, 204)
-        expected = { 'Content-Type': 'application/json',
-                     'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+        expected = { 'Access-Control-Allow-Origin': '*',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
         }
 
         # use dict comprehension to remove fields like date,
@@ -154,11 +191,10 @@
         print(f.headers)
 
         self.assertEqual(f.status_code, 204)
-        expected = { 'Content-Type': 'application/json',
-                     'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+        expected = { 'Access-Control-Allow-Origin': '*',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, POST',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'OPTIONS, GET, POST',
         }
 
         # use dict comprehension to remove fields like date,
@@ -175,11 +211,10 @@
         print(f.headers)
 
         self.assertEqual(f.status_code, 204)
-        expected = { 'Content-Type': 'application/json',
-                     'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+        expected = { 'Access-Control-Allow-Origin': '*',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
         }
 
         # use dict comprehension to remove fields like date,
@@ -195,11 +230,10 @@
         print(f.headers)
 
         self.assertEqual(f.status_code, 204)
-        expected = { 'Content-Type': 'application/json',
-                     'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+        expected = { 'Access-Control-Allow-Origin': '*',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
         }
 
         # use dict comprehension to remove fields like date,
@@ -264,8 +298,7 @@
         print(f.headers)
 
         self.assertEqual(f.status_code, 304)
-        expected = { 'Content-Type': 'application/javascript',
-                     'Vary': 'Accept-Encoding',
+        expected = { 'Vary': 'Accept-Encoding',
                      'Content-Length': '0',
         }
 
@@ -374,6 +407,57 @@
         # cleanup
         os.remove(gzfile)
 
+    def test_compression_none_etag(self):
+        # use basic auth for rest endpoint
+        f = requests.get(self.url_base() + '/rest/data/user/1/username',
+                             auth=('admin', 'sekrit'),
+                             headers = {'content-type': "",
+                                        'Accept-Encoding': "",
+                                        'Accept': '*/*'})
+        print(f.status_code)
+        print(f.headers)
+
+        self.assertEqual(f.status_code, 200)
+        expected = { 'Content-Type': 'application/json',
+                     'Access-Control-Allow-Origin': '*',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
+                     'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH'
+        }
+
+        content_str = '''{ "data": {
+                        "id": "1",
+                        "link": "http://localhost:9001/rest/data/user/1/username",
+                        "data": "admin"
+                    }
+        }'''
+        content = json.loads(content_str)
+
+
+        if (type("") == type(f.content)):
+            json_dict = json.loads(f.content)
+        else:
+            json_dict = json.loads(b2s(f.content))
+
+        # etag wil not match, creation date different
+        del(json_dict['data']['@etag']) 
+
+        # type is "class 'str'" under py3, "type 'str'" py2
+        # just skip comparing it.
+        del(json_dict['data']['type']) 
+
+        self.assertDictEqual(json_dict, content)
+
+        # verify that ETag header has no - delimiter
+        print(f.headers['ETag'])
+        with self.assertRaises(ValueError):
+            f.headers['ETag'].index('-')
+
+        # use dict comprehension to remove fields like date,
+        # content-length etc. from f.headers.
+        self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)
+
+
     def test_compression_gzip(self):
         # use basic auth for rest endpoint
         f = requests.get(self.url_base() + '/rest/data/user/1/username',
@@ -387,9 +471,9 @@
         self.assertEqual(f.status_code, 200)
         expected = { 'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
                      'Content-Encoding': 'gzip',
                      'Vary': 'Accept-Encoding',
         }
@@ -417,6 +501,13 @@
 
         self.assertDictEqual(json_dict, content)
 
+        # verify that ETag header ends with -gzip
+        try:
+            self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-gzip"$')
+        except AttributeError:
+            # python2 no assertRegex so try substring match
+            self.assertEqual(33, f.headers['ETag'].rindex('-gzip"'))
+
         # use dict comprehension to remove fields like date,
         # content-length etc. from f.headers.
         self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)
@@ -432,20 +523,19 @@
         print(f.status_code)
         print(f.headers)
 
-        # ERROR: attribute error turns into 405, not sure that's right.
         # NOTE: not compressed payload too small
-        self.assertEqual(f.status_code, 405)
+        self.assertEqual(f.status_code, 400)
         expected = { 'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
         }
 
         content = { "error":
                     {
-                        "status": 405,
-                        "msg": "'foo'"
+                        "status": 400,
+                        "msg": "Invalid attribute foo"
                     }
         }
 
@@ -514,9 +604,9 @@
         self.assertEqual(f.status_code, 200)
         expected = { 'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
                      'Content-Encoding': 'br',
                      'Vary': 'Accept-Encoding',
         }
@@ -547,6 +637,13 @@
 
         self.assertDictEqual(json_dict, content)
 
+        # verify that ETag header ends with -br
+        try:
+            self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-br"$')
+        except AttributeError:
+            # python2 no assertRegex so try substring match
+            self.assertEqual(33, f.headers['ETag'].rindex('-br"'))
+
         # use dict comprehension to remove fields like date,
         # content-length etc. from f.headers.
         self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)
@@ -560,20 +657,20 @@
                                         'Accept': '*/*'})
         print(f.status_code)
         print(f.headers)
-        # ERROR: attribute error turns into 405, not sure that's right.
+
         # Note: not compressed payload too small
-        self.assertEqual(f.status_code, 405)
+        self.assertEqual(f.status_code, 400)
         expected = { 'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
         }
 
         content = { "error":
                     {
-                        "status": 405,
-                        "msg": "'foo'"
+                        "status": 400,
+                        "msg": "Invalid attribute foo"
                     }
         }
         json_dict = json.loads(b2s(f.content))
@@ -660,9 +757,9 @@
         self.assertEqual(f.status_code, 200)
         expected = { 'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
                      'Content-Encoding': 'zstd',
                      'Vary': 'Accept-Encoding',
         }
@@ -693,6 +790,13 @@
 
         self.assertDictEqual(json_dict, content)
 
+        # verify that ETag header ends with -zstd
+        try:
+            self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-zstd"$')
+        except AttributeError:
+            # python2 no assertRegex so try substring match
+            self.assertEqual(33, f.headers['ETag'].rindex('-zstd"'))
+
         # use dict comprehension to remove fields like date,
         # content-length etc. from f.headers.
         self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)
@@ -708,20 +812,19 @@
         print(f.status_code)
         print(f.headers)
 
-        # ERROR: attribute error turns into 405, not sure that's right.
         # Note: not compressed, payload too small
-        self.assertEqual(f.status_code, 405)
+        self.assertEqual(f.status_code, 400)
         expected = { 'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*',
-                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-HTTP-Method-Override',
+                     'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
                      'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
-                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, PUT, DELETE, PATCH',
+                     'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
         }
 
         content = { "error":
                     {
-                        "status": 405,
-                        "msg": "'foo'"
+                        "status": 400,
+                        "msg": "Invalid attribute foo"
                     }
         }
 
@@ -776,3 +879,111 @@
         self.assertDictEqual({ key: value for (key, value) in
                                f.headers.items() if key in expected },
                              expected)
+
+    @pytest.mark.xfail(reason="Fails with 3600 age on circle ci not sure why")
+    def test_cache_control_css(self):
+        f = requests.get(self.url_base() + '/@@file/style.css',
+                             headers = {'content-type': "",
+                                        'Accept': '*/*'})
+        print(f.status_code)
+        print(f.headers)
+
+        self.assertEqual(f.status_code, 200)
+        self.assertEqual(f.headers['Cache-Control'], 'public, max-age=4838400')
+
+    def test_cache_control_js(self):
+        f = requests.get(self.url_base() + '/@@file/help_controls.js',
+                             headers = {'content-type': "",
+                                        'Accept': '*/*'})
+        print(f.status_code)
+        print(f.headers)
+
+        self.assertEqual(f.status_code, 200)
+        self.assertEqual(f.headers['Cache-Control'], 'public, max-age=1209600')
+
+    def test_new_issue_with_file_upload(self):
+        # Set up session to manage cookies <insert blue monster here>
+        session = requests.Session()
+
+        # login using form
+        login = {"__login_name": 'admin', '__login_password': 'sekrit', 
+                 "@action": "login"}
+        f = session.post(self.url_base()+'/', data=login)
+        # look for change in text in sidebar post login
+        self.assertIn('Hello, admin', f.text)
+
+        # create a new issue and upload a file
+        file_content = 'this is a test file\n'
+        file = {"@file": ('test1.txt', file_content, "text/plain") }
+        issue = {"title": "my title", "priority": "1", "@action": "new"}
+        f = session.post(self.url_base()+'/issue?@template=item', data=issue, files=file)
+
+        # use redirected url to determine which issue and file were created.
+        m = re.search(r'[0-9]/issue(?P<issue>[0-9]+)\?@ok_message.*file%20(?P<file>[0-9]+)%20', f.url)
+
+        # verify message in redirected url: file 1 created\nissue 1 created
+        # warning may fail if another test loads tracker with files.
+        # Escape % signs in string by doubling them. This verifies the
+        # search is working correctly.
+        # use groupdict for python2.
+        self.assertEqual('http://localhost:9001/issue%(issue)s?@ok_message=file%%20%(file)s%%20created%%0Aissue%%20%(issue)s%%20created&@template=item'%m.groupdict(), f.url)
+
+        # we have an issue display, verify filename is listed there
+        # seach for unique filename given to it.
+        self.assertIn("test1.txt", f.text)
+
+        # download file and verify content
+        f = session.get(self.url_base()+'/file%(file)s/text1.txt'%m.groupdict())
+        self.assertEqual(f.text, file_content)
+        print(f.text)
+
+    def test_new_file_via_rest(self):
+
+        session = requests.Session()
+        session.auth = ('admin', 'sekrit')
+
+        url = self.url_base() + '/rest/data/'
+        fname   = 'a-bigger-testfile'
+        d = dict(name = fname, type='application/octet-stream')
+        c = dict (content = r'xyzzy')
+        r = session.post(url + 'file', files = c, data = d,
+                          headers = {'x-requested-with': "rest"}
+        )
+
+        # was a 500 before fix for issue2551178
+        self.assertEqual(r.status_code, 201)
+        # just compare the path leave off the number
+        self.assertIn('http://localhost:9001/rest/data/file/',
+                      r.headers["location"])
+        json_dict = json.loads(r.text)
+        self.assertEqual(json_dict["data"]["link"], r.headers["location"])
+
+        # download file and verify content
+        r = session.get(r.headers["location"] +'/content')
+        json_dict = json.loads(r.text)
+        self.assertEqual(json_dict['data']['data'], c["content"])
+        print(r.text)
+
+        # Upload a file via rest interface - no auth 
+        session.auth = None
+        r = session.post(url + 'file', files = c, data = d,
+                          headers = {'x-requested-with': "rest"}
+        )
+        self.assertEqual(r.status_code, 403)
+
+        # get session variable from web form login
+        #   and use it to upload file
+        # login using form
+        login = {"__login_name": 'admin', '__login_password': 'sekrit', 
+                 "@action": "login"}
+        f = session.post(self.url_base()+'/', data=login)
+        # look for change in text in sidebar post login
+        self.assertIn('Hello, admin', f.text)
+
+        r = session.post(url + 'file', files = c, data = d,
+                          headers = {'x-requested-with': "rest"}
+        )
+        self.assertEqual(r.status_code, 201)
+        print(r.status_code)
+
+

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