comparison test/rest_common.py @ 8180:d02ce1d14acd

feat: issue2551068 - Provide way to retrieve file/msg data via rest endpoint. Use Allow header to change format of /binary_content endpoint. If Allow header for endpoint is not application/json, it will be matched against the mime type for the file. */*, text/* are supported and will return the native mime type if present. Changes: move */* mime type from static dict of supported types. It was hardcoded to return json only. Now it can return a matching non-json mime type for the /binary_content endpoint. Edited some errors to explicitly add */* mime type. Cleanups to use ', ' separation in lists of valid mime types rather than just space separated. Remove ETag header when sending raw content. See issue 2551375 for background. Doc added to rest.txt. Small format fix up (add dash) in CHANGES.txt. Make passing an unset/None/False accept_mime_type to format_dispatch_output a 500 error. This used to be the fallback to produce a 406 error after all processing had happened. It should no longer be possible to take that code path as all 406 errors (with valid accept_mime_types) are generated before processing takes place. Make format_dispatch_output handle output other than json/xml so it can send back binary_content data. Removed a spurious client.response_code = 400 that seems to not be used. Tests added for all code paths. Database setup for tests msg and file entry. This required a file upload test to change so it doesn't look for file1 as the link returned by the upload. Download the link and verify the data rather than verifying the link. Multiple formatting changes to error messages to make all lists of valid mime types ', ' an not just space separated.
author John Rouillard <rouilj@ieee.org>
date Sun, 08 Dec 2024 17:22:33 -0500
parents 2967f37e73e4
children 5fcc1a379564
comparison
equal deleted inserted replaced
8179:53afc3ffd4a2 8180:d02ce1d14acd
5 5
6 from time import sleep 6 from time import sleep
7 from datetime import datetime, timedelta 7 from datetime import datetime, timedelta
8 from roundup.anypy.cgi_ import cgi 8 from roundup.anypy.cgi_ import cgi
9 from roundup.anypy.datetime_ import utcnow 9 from roundup.anypy.datetime_ import utcnow
10 from roundup.date import Date
10 from roundup.exceptions import UsageError 11 from roundup.exceptions import UsageError
11 from roundup.test.tx_Source_detector import init as tx_Source_init 12 from roundup.test.tx_Source_detector import init as tx_Source_init
12 13
13 14
14 try: 15 try:
100 roles='User' 101 roles='User'
101 ) 102 )
102 103
103 self.db.user.set('1', address="admin@admin.com") 104 self.db.user.set('1', address="admin@admin.com")
104 self.db.user.set('2', address="anon@admin.com") 105 self.db.user.set('2', address="anon@admin.com")
106
107 # set up some more stuff for testing
108 self.db.msg.create(
109 author="1",
110 date=Date(),
111 summary="stuff",
112 content="abcdefghi\njklmnop",
113 type="text/markdown"
114 )
115
116 self.db.msg.create(
117 author="1",
118 date=Date(),
119 summary="stuff",
120 content="abcdefghi\njklmnop",
121 )
122
123 self.db.file.create(
124 name="afile",
125 content="PNG\x01abcdefghi\njklmnop",
126 type="image/png"
127 )
128
105 self.db.commit() 129 self.db.commit()
106 self.db.close() 130 self.db.close()
107 self.db = self.instance.open('joe') 131 self.db = self.instance.open('joe')
108 # Allow joe to retire 132 # Allow joe to retire
109 p = self.db.security.addPermission(name='Retire', klass='issue') 133 p = self.db.security.addPermission(name='Retire', klass='issue')
1945 (output_type, uri, error) = dof("/rest/data/issue") 1969 (output_type, uri, error) = dof("/rest/data/issue")
1946 1970
1947 self.assertEqual(self.server.client.response_code, 400) 1971 self.assertEqual(self.server.client.response_code, 400)
1948 self.assertEqual(output_type, None) 1972 self.assertEqual(output_type, None)
1949 self.assertEqual(uri, "/rest/data/issue") 1973 self.assertEqual(uri, "/rest/data/issue")
1950 self.assertIn('Unable to parse Accept Header. Invalid media type: Xyzzy I am not a mime. Acceptable types: */* application/json', error['error']['msg']) 1974 self.assertIn('Unable to parse Accept Header. Invalid media type: Xyzzy I am not a mime. Acceptable types: */*, application/json', error['error']['msg'])
1951 1975
1952 # test 5 accept mimetype is ok, param is not 1976 # test 5 accept mimetype is ok, param is not
1953 headers={"accept": "*/*; foo", 1977 headers={"accept": "*/*; foo",
1954 "content-type": env['CONTENT_TYPE'], 1978 "content-type": env['CONTENT_TYPE'],
1955 "content-length": env['CONTENT_LENGTH'], 1979 "content-length": env['CONTENT_LENGTH'],
1967 (output_type, uri, error) = dof("/rest/data/issue") 1991 (output_type, uri, error) = dof("/rest/data/issue")
1968 1992
1969 self.assertEqual(self.server.client.response_code, 400) 1993 self.assertEqual(self.server.client.response_code, 400)
1970 self.assertEqual(output_type, None) 1994 self.assertEqual(output_type, None)
1971 self.assertEqual(uri, "/rest/data/issue") 1995 self.assertEqual(uri, "/rest/data/issue")
1972 self.assertIn('Unable to parse Accept Header. Invalid param: foo. Acceptable types: */* application/json', error['error']['msg']) 1996 self.assertIn('Unable to parse Accept Header. Invalid param: foo. Acceptable types: */*, application/json', error['error']['msg'])
1997
1998 # test 6: test paths:
1999 #
2000 test_suite = [
2001 (# use binary_content on a class that doesn't support it
2002 {"path": "/rest/data/issue/1/binary_content",
2003 "accept": "",
2004 "response_code": "",
2005 "output_type": None,
2006 "uri": "/rest/data/issue/1/binary_content",
2007 "error": None
2008 }),
2009 (# use invalid class
2010 {"path": "/rest/data/notissue/1/binary_content",
2011 "accept": "",
2012 "response_code": "",
2013 "output_type": None,
2014 "uri": "/rest/data/notissue/1/binary_content",
2015 "error": None
2016 }),
2017 (# use invalid id
2018 {"path": "/rest/data/issue/99/binary_content",
2019 "accept": "",
2020 "response_code": "",
2021 "output_type": None,
2022 "uri": "/rest/data/issue/99/binary_content",
2023 "error": None
2024 }),
2025 ]
2026
2027 for test in test_suite:
2028 self.server.client.response_code = ""
2029 env = { "CONTENT_TYPE": "application/json",
2030 "CONTENT_LENGTH": len(body),
2031 "REQUEST_METHOD": "GET"
2032 }
2033 self.server.client.env.update(env)
2034 headers={"accept": test["accept"] or
2035 "application/zot; version=1; q=0.75, "
2036 "application/json; version=1; q=0.5",
2037 "content-type": env['CONTENT_TYPE'],
2038 "content-length": env['CONTENT_LENGTH'],
2039 }
2040
2041 self.headers=headers
2042 # we need to generate a FieldStorage the looks like
2043 # FieldStorage(None, None, 'string') rather than
2044 # FieldStorage(None, None, [])
2045 body_file=BytesIO(body) # FieldStorage needs a file
2046 form = client.BinaryFieldStorage(
2047 body_file,
2048 headers=headers,
2049 environ=env)
2050 self.server.client.request.headers.get=self.get_header
2051 (output_type, uri, error) = dof(test["path"])
2052
2053 self.assertEqual(self.server.client.response_code,
2054 test["response_code"])
2055 self.assertEqual(output_type, test["output_type"])
2056 self.assertEqual(uri, test["uri"])
2057 self.assertEqual(error, test["error"])
2058
2059 # test 7: test paths:
2060 #
2061 test_suite = [
2062 (# use wildcard accept on item and get back json output
2063 {"path": "/rest/data/file/1",
2064 "accept": "*/*",
2065 "response_code": "",
2066 "output_type": "json",
2067 "uri": "/rest/data/file/1",
2068 "error": None,
2069 "has_nosniff": False,
2070 }),
2071 (# use wildcard accept and get back file's actual mime type
2072 {"path": "/rest/data/file/1/binary_content",
2073 "accept": "*/*",
2074 "response_code": "",
2075 "output_type": "image/png",
2076 "uri": "/rest/data/file/1/binary_content",
2077 "error": None,
2078 "has_nosniff": True,
2079
2080 }),
2081 (# use json accept and get back json
2082 {"path": "/rest/data/file/1/binary_content",
2083 "accept": "application/json",
2084 "response_code": "",
2085 "output_type": "json",
2086 "uri": "/rest/data/file/1/binary_content",
2087 "error": None,
2088 "has_nosniff": False,
2089 }),
2090 (# use json accept with invalid number version and get back error
2091 {"path": "/rest/data/file/1/binary_content",
2092 "accept": "application/json; q=0.5; version=22",
2093 "response_code": 406,
2094 "output_type": None,
2095 "uri": "/rest/data/file/1/binary_content",
2096 "error": {'error': {'status': 406, 'msg': 'Unrecognized api version: 22. See /rest without specifying api version for supported versions.'}},
2097 "has_nosniff": False,
2098 }),
2099 (# use json accept with invalid string version and get back error
2100 {"path": "/rest/data/file/1/binary_content",
2101 "accept": "application/json; q=0.5; version=z",
2102 "response_code": 406,
2103 "output_type": None,
2104 "uri": "/rest/data/file/1/binary_content",
2105 "error": {'error': {'status': 406, 'msg': 'Unrecognized api version: z. See /rest without specifying api version for supported versions.'}},
2106 "has_nosniff": False,
2107 }),
2108 (# use octet-stream accept and get back octet-stream mime type
2109 {"path": "/rest/data/file/1/binary_content",
2110 "accept": "application/octet-stream; q=0.9, */*; q=0.5",
2111 "response_code": "",
2112 "output_type": "application/octet-stream",
2113 "uri": "/rest/data/file/1/binary_content",
2114 "error": None,
2115 "has_nosniff": True,
2116 }),
2117 (# use image/png accept and get back image/png mime type
2118 {"path": "/rest/data/file/1/binary_content",
2119 "accept": "application/octet-stream; q=0.9, image/png",
2120 "response_code": "",
2121 "output_type": "image/png",
2122 "uri": "/rest/data/file/1/binary_content",
2123 "error": None,
2124 "has_nosniff": True,
2125 }),
2126 (# use invalid accept and get back error
2127 {"path": "/rest/data/file/1/binary_content",
2128 "accept": "image/svg+html",
2129 "response_code": 406,
2130 "output_type": None,
2131 "uri": "/rest/data/file/1/binary_content",
2132 "error": {'error':
2133 {'status': 406, 'msg': "Requested content type(s) 'image/svg+html' not available.\nAcceptable mime types are: */*, application/octet-stream, image/png"}},
2134 "has_nosniff": False,
2135 }),
2136 (# use wildcard accept and get back msg's actual mime type
2137 {"path": "/rest/data/msg/1/binary_content",
2138 "accept": "*/*",
2139 "response_code": "",
2140 "output_type": "text/markdown",
2141 "uri": "/rest/data/msg/1/binary_content",
2142 "error": None,
2143 "has_nosniff": True,
2144 }),
2145 (# use octet-stream accept and get back octet-stream mime type
2146 {"path": "/rest/data/msg/1/binary_content",
2147 "accept": "application/octet-stream; q=0.9, */*; q=0.5",
2148 "response_code": "",
2149 "output_type": "application/octet-stream",
2150 "uri": "/rest/data/msg/1/binary_content",
2151 "error": None,
2152 "has_nosniff": True,
2153 }),
2154
2155 (# use wildcard text accept and get back msg's actual mime type
2156 {"path": "/rest/data/msg/1/binary_content",
2157 "accept": "text/*",
2158 "response_code": "",
2159 "output_type": "text/markdown",
2160 "uri": "/rest/data/msg/1/binary_content",
2161 "error": None,
2162 "has_nosniff": True,
2163 }),
2164 (# use wildcard text accept and get back file's actual mime type
2165 {"path": "/rest/data/msg/1/binary_content",
2166 "accept": "text/markdown",
2167 "response_code": "",
2168 "output_type": "text/markdown",
2169 "uri": "/rest/data/msg/1/binary_content",
2170 "error": None,
2171 "has_nosniff": True,
2172 }),
2173 (# use text/plain accept and get back test/plain
2174 {"path": "/rest/data/msg/1/binary_content",
2175 "accept": "text/plain",
2176 "response_code": 406,
2177 "output_type": None,
2178 "uri": "/rest/data/msg/1/binary_content",
2179 "error": {'error':
2180 {'status': 406, 'msg':
2181 "Requested content type(s) 'text/plain' not available.\nAcceptable mime types are: */*, application/octet-stream, text/*, text/markdown"}},
2182 "has_nosniff": False,
2183 }),
2184 (# use wildcard accept and get back default msg mime type
2185 {"path": "/rest/data/msg/2/binary_content",
2186 "accept": "*/*",
2187 "response_code": "",
2188 "output_type": "text/*",
2189 "uri": "/rest/data/msg/2/binary_content",
2190 "error": None,
2191 "has_nosniff": True,
2192 }),
2193 (# use text/* and get back text/*
2194 {"path": "/rest/data/msg/2/binary_content",
2195 "accept": "text/*",
2196 "response_code": "",
2197 "output_type": "text/*",
2198 "uri": "/rest/data/msg/2/binary_content",
2199 "error": None,
2200 "has_nosniff": True,
2201 }),
2202 (# use text/markdown and get back error
2203 {"path": "/rest/data/msg/2/binary_content",
2204 "accept": "text/markdown",
2205 "response_code": 406,
2206 "output_type": None,
2207 "uri": "/rest/data/msg/2/binary_content",
2208 "error": {'error':
2209 {'status': 406, 'msg':
2210 "Requested content type(s) 'text/markdown' not available.\nAcceptable mime types are: */*, application/octet-stream, text/*"}},
2211 "has_nosniff": False,
2212 }),
2213 (# use error accept and get back error
2214 {"path": "/rest/data/msg/1/binary_content",
2215 "accept": "text/markdown, q=2",
2216 "response_code": 400,
2217 "output_type": None,
2218 "uri": "/rest/data/msg/1/binary_content",
2219 "error": {'error':
2220 {'status': 400, 'msg':
2221 'Unable to parse Accept Header. Invalid media type: q=2. Acceptable types: */*, application/json'}},
2222 "has_nosniff": False,
2223 }),
2224 (# use text/* but override with extension of .json get back json
2225 {"path": "/rest/data/msg/2/binary_content.json",
2226 "accept": "text/*",
2227 "response_code": "",
2228 "output_type": "json",
2229 "uri": "/rest/data/msg/2/binary_content",
2230 "error": None,
2231 "has_nosniff": False,
2232 }),
2233 (# use text/* but override with extension of .jon get back error
2234 {"path": "/rest/data/msg/2/binary_content.jon",
2235 "accept": "text/*",
2236 "response_code": 406,
2237 "output_type": None,
2238 "uri": "/rest/data/msg/2/binary_content.jon",
2239 "error": {'error':
2240 {'status': 406, 'msg':
2241 "Content type 'jon' requested in URL is not available.\nAcceptable types: json\n"}},
2242 "has_nosniff": False,
2243 }),
2244 ]
2245
2246 for test in test_suite:
2247 print(test)
2248 self.server.client.response_code = ""
2249 self.server.client.additional_headers = {}
2250 env = { "CONTENT_TYPE": "application/json",
2251 "CONTENT_LENGTH": len(body),
2252 "REQUEST_METHOD": "GET"
2253 }
2254 self.server.client.env.update(env)
2255 headers={"accept": test["accept"] or
2256 "application/zot; version=1; q=0.75, "
2257 "application/json; version=1; q=0.5",
2258 "content-type": env['CONTENT_TYPE'],
2259 "content-length": env['CONTENT_LENGTH'],
2260 }
2261
2262 self.headers=headers
2263 # we need to generate a FieldStorage the looks like
2264 # FieldStorage(None, None, 'string') rather than
2265 # FieldStorage(None, None, [])
2266 body_file=BytesIO(body) # FieldStorage needs a file
2267 form = client.BinaryFieldStorage(
2268 body_file,
2269 headers=headers,
2270 environ=env)
2271 self.server.client.request.headers.get=self.get_header
2272 (output_type, uri, error) = dof(test["path"])
2273
2274 self.assertEqual(self.server.client.response_code,
2275 test["response_code"])
2276 self.assertEqual(output_type, test["output_type"])
2277 self.assertEqual(uri, test["uri"])
2278 print(error)
2279 self.assertEqual(error, test["error"])
2280 if test["has_nosniff"]:
2281 self.assertIn("X-Content-Type-Options",
2282 self.server.client.additional_headers)
2283 self.assertEqual("nosniff",
2284 self.server.client.additional_headers['X-Content-Type-Options'])
2285 else:
2286 self.assertNotIn("X-Content-Type-Options",
2287 self.server.client.additional_headers)
1973 2288
1974 def testDispatchBadAccept(self): 2289 def testDispatchBadAccept(self):
1975 # simulate: /rest/data/issue expect failure unknown accept settings 2290 # simulate: /rest/data/issue expect failure unknown accept settings
1976 body=b'{ "title": "Joe Doe has problems", \ 2291 body=b'{ "title": "Joe Doe has problems", \
1977 "nosy": [ "1", "3" ], \ 2292 "nosy": [ "1", "3" ], \
2084 form) 2399 form)
2085 2400
2086 print(results) 2401 print(results)
2087 self.assertEqual(self.server.client.response_code, 400) 2402 self.assertEqual(self.server.client.response_code, 400)
2088 json_dict = json.loads(b2s(results)) 2403 json_dict = json.loads(b2s(results))
2089 self.assertIn('Unable to parse Accept Header. Invalid media type: Xyzzy I am not a mime. Acceptable types: */* application/json', json_dict['error']['msg']) 2404 self.assertIn('Unable to parse Accept Header. Invalid media type: Xyzzy I am not a mime. Acceptable types: */*, application/json', json_dict['error']['msg'])
2090 2405
2091 # test 5 accept mimetype is ok, param is not 2406 # test 5 accept mimetype is ok, param is not
2092 headers={"accept": "*/*; foo", 2407 headers={"accept": "*/*; foo",
2093 "content-type": env['CONTENT_TYPE'], 2408 "content-type": env['CONTENT_TYPE'],
2094 "content-length": env['CONTENT_LENGTH'], 2409 "content-length": env['CONTENT_LENGTH'],
2108 form) 2423 form)
2109 2424
2110 print(results) 2425 print(results)
2111 self.assertEqual(self.server.client.response_code, 400) 2426 self.assertEqual(self.server.client.response_code, 400)
2112 json_dict = json.loads(b2s(results)) 2427 json_dict = json.loads(b2s(results))
2113 self.assertIn('Unable to parse Accept Header. Invalid param: foo. Acceptable types: */* application/json', json_dict['error']['msg']) 2428 self.assertIn('Unable to parse Accept Header. Invalid param: foo. Acceptable types: */*, application/json', json_dict['error']['msg'])
2114 2429
2115 def testStatsGen(self): 2430 def testStatsGen(self):
2116 # check stats being returned by put and get ops 2431 # check stats being returned by put and get ops
2117 # using dispatch which parses the @stats query param 2432 # using dispatch which parses the @stats query param
2118 2433
3352 self.assertEqual(self.dummy_client.response_code, 201) 3667 self.assertEqual(self.dummy_client.response_code, 201)
3353 fileid = results['data']['id'] 3668 fileid = results['data']['id']
3354 results = self.server.get_element('file', fileid, self.empty_form) 3669 results = self.server.get_element('file', fileid, self.empty_form)
3355 results = results['data'] 3670 results = results['data']
3356 self.assertEqual(self.dummy_client.response_code, 200) 3671 self.assertEqual(self.dummy_client.response_code, 200)
3357 self.assertEqual(results['attributes']['content'], 3672 self.assertIn("link", results['attributes']['content'])
3358 {'link': 'http://tracker.example/cgi-bin/roundup.cgi/bugs/file1/'})
3359 3673
3360 # File content is only shown with verbose=3 3674 # File content is only shown with verbose=3
3361 form = cgi.FieldStorage() 3675 form = cgi.FieldStorage()
3362 form.list = [ 3676 form.list = [
3363 cgi.MiniFieldStorage('@verbose', '3'), 3677 cgi.MiniFieldStorage('@verbose', '3'),

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