Mercurial > p > roundup > code
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'), |
