diff test/test_cgi.py @ 7906:470616e64414

issue2551334 - get test suite running under windows Fix test_cgi under windows. Few classes of errors: 1) comparing paths with a/b (unix) to a\b (windows). Use normpath to fix it. Also change regexps used for path matching to use either \ or / for directory separators. 2) database not being closed preventing deletion of test case directory. Windows doesn't allow deletion of open files. In some cases replaced calling client.inner_mail() with main() because main() makes sure to close the database. In other cases assigned self.db = client.db beacuse client reopens the database and closes the original self.db. Reassigning allows tearDown to close the last opened handle to a db. 3) commit the admin password to the database. If it's not commited calling the code sometimes comes up with a different admin password. Not sure why we don't see this on linux. 4) run commit() on database so that sqlite databases can be closed and deleted. Unit tests don't call the main entry points that have finally clauses to close the databases properly, so do it in the test. 5) split tests that try to resolve symbolic links in the template directory to a separate method. Under windows user needs special permissions to creae symbolic links, so I report the method is skipped if creating a link fails.
author John Rouillard <rouilj@ieee.org>
date Sat, 27 Apr 2024 23:19:51 -0400
parents a430339f55e6
children 28aa76443f58
line wrap: on
line diff
--- a/test/test_cgi.py	Sat Apr 27 21:03:20 2024 -0400
+++ b/test/test_cgi.py	Sat Apr 27 23:19:51 2024 -0400
@@ -14,6 +14,8 @@
 import pytest
 import copy
 
+from os.path import normpath
+
 from roundup.anypy.cgi_ import cgi
 from roundup.cgi import client, actions, exceptions
 from roundup.cgi.exceptions import FormError, NotFound, Redirect
@@ -933,6 +935,9 @@
         e2 = HTMLProperty.is_edit_ok
         HTMLProperty.is_edit_ok = lambda x : True
         cl.inner_main()
+        # The original self.db has been changed. Assign the new
+        # cl.db to self.db so it gets closed at the end of the test.
+        self.db = cl.db
         _HTMLItem.is_edit_ok = e1
         HTMLProperty.is_edit_ok = e2
         self.assertEqual(len(out), 1)
@@ -969,6 +974,7 @@
             out.append(s)
         cl.write_html = wh
         cl.main()
+        self.db = cl.db # to close new db handle from main() at tearDown
         self.assertFalse('HTTP_PROXY' in cl.env)
         self.assertFalse('HTTP_PROXY' in os.environ)
 
@@ -1036,7 +1042,7 @@
 
         # test with no headers. Default config requires that 1 header
         # is present and passes checks.
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Unable to verify sufficient headers')
         print("result of subtest 1:", out[0])
         self.assertNotEqual(match_at, -1)
@@ -1045,7 +1051,7 @@
         # all the rest of these allow at least one header to pass
         # and the edit happens with a redirect back to issue 1
         cl.env['HTTP_REFERER'] = 'http://whoami.com/path/'
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message')
         print("result of subtest 2:", out[0])
         self.assertEqual(match_at, 0)
@@ -1053,7 +1059,7 @@
         del(out[0])
 
         cl.env['HTTP_ORIGIN'] = 'http://whoami.com'
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message')
         print("result of subtest 3:", out[0])
         self.assertEqual(match_at, 0)
@@ -1067,7 +1073,7 @@
         # the proxy's name for the web server and not the name
         # thatis exposed to the world.
         cl.env['HTTP_HOST'] = 'frontend1.whoami.net'
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message')
         print("result of subtest 4:", out[0])
         self.assertNotEqual(match_at, -1)
@@ -1076,7 +1082,7 @@
         del(out[0])
 
         cl.env['HTTP_HOST'] = 'whoami.com'
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message')
         print("result of subtest 5:", out[0])
         self.assertEqual(match_at, 0)
@@ -1087,7 +1093,7 @@
         cl.env['HTTP_X_FORWARDED_HOST'] = 'whoami.net'
         # this raises an error as the header check passes and 
         # it did the edit and tries to send mail.
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Invalid X-FORWARDED-HOST whoami.net')
         print("result of subtest 6:", out[0])
         self.assertNotEqual(match_at, -1)
@@ -1100,7 +1106,7 @@
 
         # roundup will report a missing token.
         cl.db.config['WEB_CSRF_ENFORCE_TOKEN'] = 'required'
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find("<p>We can't validate your session (csrf failure). Re-enter any unsaved data and try again.</p>")
         print("result of subtest 6a:", out[0], match_at)
         self.assertEqual(match_at, 33)
@@ -1109,10 +1115,10 @@
 
         form2 = copy.copy(form)
         form2.update({'@csrf': 'booogus'})
-        # add a bogus csrf field to the form and rerun the inner_main
+        # add a bogus csrf field to the form and rerun main
         cl.form = db_test_base.makeForm(form2)
 
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find("We can't validate your session (csrf failure). Re-enter any unsaved data and try again.")
         print("result of subtest 7:", out[0])
         self.assertEqual(match_at, 36)
@@ -1129,9 +1135,9 @@
         self.assertEqual(isitthere, True)
 
         form2.update({'@csrf': nonce})
-        # add a real csrf field to the form and rerun the inner_main
+        # add a real csrf field to the form and rerun main
         cl.form = db_test_base.makeForm(form2)
-        cl.inner_main()
+        cl.main()
         # csrf passes and redirects to the new issue.
         match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message')
         print("result of subtest 9:", out[0])
@@ -1139,7 +1145,7 @@
         del(out[0])
 
         # try a replay attack
-        cl.inner_main()
+        cl.main()
         # This should fail as token was wiped by last run.
         match_at=out[0].find("We can't validate your session (csrf failure). Re-enter any unsaved data and try again.")
         print("replay of csrf after post use", out[0])
@@ -1153,9 +1159,9 @@
         form2 = copy.copy(form)
         nonce = anti_csrf_nonce(cl)
         form2.update({'@csrf': nonce})
-        # add a real csrf field to the form and rerun the inner_main
+        # add a real csrf field to the form and rerun main
         cl.form = db_test_base.makeForm(form2)
-        cl.inner_main()
+        cl.main()
         # csrf passes but fail creating new issue because not a post
         match_at=out[0].find('<p>Invalid request</p>')
         print("result of subtest 11:", out[0])
@@ -1171,7 +1177,7 @@
         # since get deleted the token.
         cl.env.update({'REQUEST_METHOD': 'POST'})
         print(cl.env)
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find("We can't validate your session (csrf failure). Re-enter any unsaved data and try again.")
         print("post failure after get", out[0])
         print("result of subtest 13:", out[0])
@@ -1184,7 +1190,7 @@
         # this should not redirect as it is not an API call.
         cl.db.config.WEB_ALLOWED_API_ORIGINS = "  *  "
         cl.env['HTTP_ORIGIN'] = 'https://baz.edu'
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Invalid Origin https://baz.edu')
         print("result of subtest invalid origin:", out[0])
         self.assertEqual(match_at, 36)
@@ -1197,7 +1203,7 @@
         cl.db.config.WEB_ALLOWED_API_ORIGINS = "  *  "
         cl.env['HTTP_ORIGIN'] = 'http://whoami.com'
         cl.env['HTTP_REFERER'] = 'https://baz.edu/path/'
-        cl.inner_main()
+        cl.main()
         match_at=out[0].find('Invalid Referer: https://baz.edu/path/')
         print("result of subtest invalid referer:", out[0])
         self.assertEqual(match_at, 36)
@@ -1216,6 +1222,7 @@
         # set the password for admin so we can log in.
         passwd=password.Password('admin')
         self.db.user.set('1', password=passwd)
+        self.db.commit()
 
         out = []
         def wh(s):
@@ -1248,6 +1255,7 @@
         cl.write = wh # capture output
 
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from main() at tearDown
         print(b2s(out[0]))
         expected="""
         {
@@ -1297,6 +1305,7 @@
         # Should return explanation because content type is text/plain
         # and not text/xml
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from main() at tearDown
         self.assertIn('Access-Control-Allow-Credentials',
                       cl.additional_headers)
 
@@ -1327,6 +1336,7 @@
         # Should return explanation because content type is text/plain
         # and not text/xml
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from main() at tearDown
         self.assertNotIn('Access-Control-Allow-Credentials',
                       cl.additional_headers)
 
@@ -1353,6 +1363,7 @@
                             'HTTP_X_REQUESTED_WITH': 'rest',
                         }, form)
         cl.db = self.db
+        self.db = cl.db # to close new db handle from main() at tearDown
         cl.base = 'http://whoami.com/path/'
         cl._socket_op = lambda *x : True
         cl._error_message = []
@@ -1366,6 +1377,7 @@
                                       
         cl.write = wh # capture output
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from main() at tearDown
         self.assertEqual(json.loads(b2s(out[0])),
                          json.loads(expected)
         )
@@ -1405,6 +1417,7 @@
         # Should return explanation because content type is text/plain
         # and not text/xml
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from main() at tearDown
         self.assertIn('Access-Control-Allow-Credentials',
                       cl.additional_headers)
 
@@ -1437,6 +1450,7 @@
         # Should return explanation because content type is text/plain
         # and not text/xml
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from main() at tearDown
         self.assertNotIn('Access-Control-Allow-Credentials', cl.additional_headers)
 
         self.assertEqual(json.loads(b2s(out[0])),json.loads(expected))
@@ -1474,6 +1488,7 @@
 
         cl.write = wh # capture output
         cl.handle_rest()
+        self.db = cl.db  # to close new db handle from handle_rest at tearDown
 
         _py3 = sys.version_info[0] > 2
 
@@ -1534,6 +1549,7 @@
 
         cl.write = wh # capture output
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from handle_rest at tearDown
         self.assertEqual(out[0], '')  # 204 options returns no data
 
         expected_headers = {
@@ -1588,6 +1604,7 @@
 
         cl.write = wh # capture output
         cl.handle_rest()
+        self.db = cl.db # to close new db handle from handle_rest at tearDown
 
         self.assertEqual(cl.response_code, 400)
 
@@ -2369,7 +2386,8 @@
         # Check the returned values.
         cl.serve_static_file("issue.index.html")
         self.assertEqual(output[0][1], "text/html")
-        self.assertEqual(output[0][3], "_test_cgi_form/html/issue.index.html")
+        self.assertEqual(output[0][3],
+                         normpath('_test_cgi_form/html/issue.index.html'))
         del output[0] # reset output buffer
 
         # stop searching TEMPLATES for the files.
@@ -2382,7 +2400,8 @@
         cl.instance.config['STATIC_FILES'] = 'html -'
         cl.serve_static_file("issue.index.html")
         self.assertEqual(output[0][1], "text/html")
-        self.assertEqual(output[0][3], "_test_cgi_form/html/issue.index.html")
+        self.assertEqual(output[0][3],
+                         normpath('_test_cgi_form/html/issue.index.html'))
         del output[0] # reset output buffer
 
         # set the list of files and do not look at the templates directory
@@ -2391,13 +2410,15 @@
         # find file in first directory
         cl.serve_static_file("messagesummary.py")
         self.assertEqual(output[0][1], "text/x-python")
-        self.assertEqual(output[0][3], "_test_cgi_form/detectors/messagesummary.py")
+        self.assertEqual(output[0][3],
+                         normpath( "_test_cgi_form/detectors/messagesummary.py"))
         del output[0] # reset output buffer
 
         # find file in second directory
         cl.serve_static_file("README.txt")
         self.assertEqual(output[0][1], "text/plain")
-        self.assertEqual(output[0][3], "_test_cgi_form/extensions/README.txt")
+        self.assertEqual(output[0][3],
+                         normpath("_test_cgi_form/extensions/README.txt"))
         del output[0] # reset output buffer
 
         # make sure an embedded - ends the searching.
@@ -2412,14 +2433,16 @@
         # find file now in first directory
         cl.serve_static_file("README.txt")
         self.assertEqual(output[0][1], "text/plain")
-        self.assertEqual(output[0][3], "_test_cgi_form/detectors/README.txt")
+        self.assertEqual(output[0][3],
+                         normpath("_test_cgi_form/detectors/README.txt"))
         del output[0] # reset output buffer
 
         cl.instance.config['STATIC_FILES'] = ' detectors extensions '
         # make sure lack of trailing - allows searching TEMPLATES
         cl.serve_static_file("issue.index.html")
         self.assertEqual(output[0][1], "text/html")
-        self.assertEqual(output[0][3], "_test_cgi_form/html/issue.index.html")
+        self.assertEqual(output[0][3],
+                         normpath("_test_cgi_form/html/issue.index.html"))
         del output[0] # reset output buffer
 
         # Make STATIC_FILES a single element.
@@ -2427,7 +2450,8 @@
         # find file now in first directory
         cl.serve_static_file("messagesummary.py")
         self.assertEqual(output[0][1], "text/x-python")
-        self.assertEqual(output[0][3], "_test_cgi_form/detectors/messagesummary.py")
+        self.assertEqual(output[0][3],
+                         normpath("_test_cgi_form/detectors/messagesummary.py"))
         del output[0] # reset output buffer
 
         # make sure files found in subdirectory
@@ -2436,7 +2460,8 @@
         # use subdir in filename
         cl.serve_static_file("css/README.css")
         self.assertEqual(output[0][1], "text/css")
-        self.assertEqual(output[0][3], "_test_cgi_form/detectors/css/README.css")
+        self.assertEqual(output[0][3],
+                         normpath("_test_cgi_form/detectors/css/README.css"))
         del output[0] # reset output buffer
         
         cl.Cache_Control['text/css'] = 'public, max-age=3600'
@@ -2446,7 +2471,8 @@
         f = open('_test_cgi_form/html/css/README1.css', 'a').close()
         cl.serve_static_file("README1.css")
         self.assertEqual(output[0][1], "text/css")
-        self.assertEqual(output[0][3], "_test_cgi_form/html/css/README1.css")
+        self.assertEqual(output[0][3],
+                         normpath("_test_cgi_form/html/css/README1.css"))
         self.assertTrue( "Cache-Control" in cl.additional_headers )
         self.assertEqual( cl.additional_headers,
                           {'Cache-Control': 'public, max-age=3600'} )
@@ -2455,7 +2481,8 @@
         cl.Cache_Control['README1.css'] = 'public, max-age=60'
         cl.serve_static_file("README1.css")
         self.assertEqual(output[0][1], "text/css")
-        self.assertEqual(output[0][3], "_test_cgi_form/html/css/README1.css")
+        self.assertEqual(output[0][3],
+                         normpath("_test_cgi_form/html/css/README1.css"))
         self.assertTrue( "Cache-Control" in cl.additional_headers )
         self.assertEqual( cl.additional_headers,
                           {'Cache-Control': 'public, max-age=60'} )
@@ -2943,11 +2970,6 @@
         shutil.copyfile(self.dirname + "/html/user.item.html",
                         self.dirname + "/user.item.html")
 
-        # create link outside the html subdir. This should fail due to
-        # path traversal check.
-        os.symlink("../../user.item.html", subdir + "/user.item.html")
-        # it will be removed and replaced by a later test
-
         # make sure a simple non-subdir template works.
         # user.item.html exists so this works.
         # note that the extension is not included just the basename
@@ -2977,6 +2999,34 @@
         r = t.selectTemplate("issue", "subdir/item")
         self.assertEqual("subdir/issue.item", r)
 
+    def testTemplateSubdirectory_symlink(self):
+        # test for templates in subdirectories using symlinks.
+        # this doesn't work under windows unless you have special
+        # permissions
+
+        # make the directory
+        subdir = self.dirname + "/html/subdir"
+        os.mkdir(subdir)
+
+        # get the client instance The form is needed to initialize,
+        # but not used since I call selectTemplate directly.
+        t = client.Client(self.instance, "user",
+                {'PATH_INFO':'/user', 'REQUEST_METHOD':'POST'},
+         form=db_test_base.makeForm({"@template": "item"}))
+
+        # create link outside the html subdir. This should fail due to
+        # path traversal check.
+        try:
+            os.symlink("../../user.item.html", subdir + "/user.item.html")
+        except OSError as e:
+            # windows requires special privs for symbolic links
+            allowed_error = 'A required privilege is not held by the client'
+            if not e.args[1] == allowed_error:
+                raise
+            pytest.skip("User does not have permission to create symbolic links under Windows")
+
+        # it will be removed and replaced by a later test
+
         # there is a self.directory + /html/subdir/user.item.html file,
         # but it is a link to self.dir /user.item.html which is outside
         # the html subdir so is rejected by the path traversal check.
@@ -3197,7 +3247,7 @@
         self.assertRegex(self._caplog.record_tuples[0][2], (
             r"^Found an incorrect token when expandfile applied "
             r"string subsitution on "
-            r"'[^']*/_test_template/html/file_with_broken_expand_type.js'. "
+            r"'[^']*[\\/]_test_template[\\/]html[\\/]file_with_broken_expand_type.js'. "
             r"ValueError\('incomplete format'\) was raised. Check the format "
             r"of your named conversion specifiers."))
         self._caplog.clear()
@@ -3240,7 +3290,7 @@
         # match the changable filename directory
         self.assertRegex(self._caplog.record_tuples[0][2], (
             r"When running "
-            r"expandfile\('[^']*/_test_template/html/file_with_missing.js'\) "
+            r"expandfile\('[^']*[\\/]_test_template[\\/]html[\\/]file_with_missing.js'\) "
             r"in 'home' there was no value for token: 'idontexist'."))
         self._caplog.clear()
         r = None
@@ -3275,7 +3325,7 @@
         self.assertRegex(self._caplog.record_tuples[0][2], (
             r"^Found an incorrect token when expandfile applied "
             r"string subsitution on "
-            r"'[^']*/_test_template/html/file_with_bare_%.js'. "
+            r"'[^']*[\\/]_test_template[\\/]html[\\/]file_with_bare_%.js'. "
             r"ValueError\("
             r"'unsupported format character ' ' \(0x20\) at index 12'\) was "
             r"raised. Check the format "
@@ -3365,6 +3415,10 @@
         self.assertEqual(result, expected)
         self.assertEqual(self.client.response_code, 400)
 
+        # handle outstanding commits since we are not using the
+        # standard entry points.
+        self.db.commit()
+
     #
     # SECURITY
     #
@@ -3410,6 +3464,11 @@
         with self.assertRaises(NotFound) as cm:
             actions.ExportCSVWithIdAction(cl).handle()
 
+        # commit changes so db can be properly closed on windows.
+        # because we are testing the backend method and not using
+        # cl.main() that handles db commit/close, we need to do this.
+        cl.db.commit()
+        
 class SqliteNativeCgiTest(unittest.TestCase, testFtsQuery):
     """All of the rest of the tests use anydbm as the backend.
        This class tests renderContext for fulltext search.

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